Skip to content

Multiplayer

Available since v0.48.0

maypop.multiplayer connects the people who have an app open at the same time. Traffic goes directly between their devices, end-to-end encrypted; Maypop only introduces them. Nothing is stored: when the last person leaves, the session is gone. Anything that must outlast the session belongs in the KV store.

There are two building blocks:

You want Use
who’s here, whose turn it is, a shared board at human speed join() — a room
cursors, live positions, game ticks, chat-like messages connect() — a message session
interface MaypopMultiplayer {
join(
roomId?: string,
opts?: { presenceTimeoutMs?: number },
): Promise<MaypopRoom>;
connect(roomId?: string): Promise<MaypopP2pSession>;
}

Room ids are 1–64 characters of letters, digits, _, . and -; the default is "main". Use different ids for different tables, matches or documents.

interface MaypopRoomPeer {
id: string;
username: string | null;
avatarUrl: string | null;
state: unknown;
updatedAt: string;
self: boolean;
}
interface MaypopRoom {
readonly id: string;
readonly me: string;
readonly peers: MaypopRoomPeer[];
readonly state: unknown;
setState(value: unknown): void;
setMyState(value: unknown): void;
subscribe(cb: (room: MaypopRoom) => void): () => void;
leave(): Promise<void>;
}

A room has one shared value (state, last write wins) and one value per person (setMyState, which never conflicts). Rapid writes are merged, so rooms suit changes at the pace people make them.

await maypop.ready();
const room = await maypop.multiplayer.join("table-1");
room.subscribe((r) => {
renderPlayers(r.peers.map((p) => ({ name: p.username, ready: p.state })));
renderBoard(r.state);
});
room.setMyState({ ready: true });
  • Someone who joins late receives the current state from the people already there.
  • A peer drops out of peers after presenceTimeoutMs without a heartbeat (default and maximum 45 seconds).
  • Read-only viewers join as spectators: they see the room but aren’t in peers, and their writes fail with maypop/read-only.
interface MaypopP2pPeer {
id: string | null;
endpointId: string;
username: string | null;
avatarUrl: string | null;
self: boolean;
connected: boolean;
}
interface MaypopP2pSession {
readonly id: string;
readonly me: string;
readonly peers: MaypopP2pPeer[];
send(endpointId: string, payload: unknown): Promise<void>;
broadcast(payload: unknown): Promise<number>;
onMessage(cb: (msg: { from: string; payload: unknown }) => void): () => void;
subscribe(cb: (session: MaypopP2pSession) => void): () => void;
leave(): Promise<void>;
}
const session = await maypop.multiplayer.connect("canvas");
session.onMessage(({ from, payload }) => moveCursor(from, payload));
window.onpointermove = (e) => session.broadcast({ x: e.clientX, y: e.clientY });
  • Payloads are JSON, up to about 1 MB.
  • Delivery is best effort and unordered, with no history: a message sent while someone is away never reaches them.
  • send to a peer that isn’t connected fails with maypop/not-connected.
  • Peers find each other within a few seconds. Reloading the page gives a person a new endpointId.

The multiplayer engine downloads the first time an app calls it, so join when the person starts a session rather than on page load.