| name | multiplayer-p2p |
| description | Peer-to-peer realtime multiplayer over WebRTC data channels: every user of the deployed app connects directly to every other user (full mesh), the server only brokers the handshake at /api/rtc. Lowest possible latency, zero per-message server cost. Use for 2-8 player co-op/casual realtime: shared cursors, drawing, party games, casual action. Triggers: p2p, peer to peer, webrtc, low latency multiplayer, direct connection.
|
| metadata | {"short-description":"WebRTC P2P mesh, signaled at /api/rtc"} |
| user-invocable | false |
Multiplayer (WebRTC peer-to-peer)
All visitors on the same deployed domain join one default room, opening a
native WebRTC data channel directly to every other visitor — game traffic
itself never touches a server. A tiny relay at /api/rtc handles only the
routing of the connection handshake (SDP/ICE) while peers connect. What you
use from the kit is client-side only; the relay is yours — keep it as
drafted below, or serve the same RtcPollResponse shape from any store.
Latency is browser↔browser (often 5–40ms) with zero per-tick server cost.
| Piece | Path |
|---|
| Mesh primitive (start here) | P2PRoom from @/lib/multiplayer |
| React room binding (optional, you create) | src/lib/multiplayer/use-p2p-room.ts |
| Signaling relay (you create) | src/lib/multiplayer/signaling.server.ts |
| HTTP mount (you create) | src/routes/api/rtc.ts |
Trust model — read before choosing P2P. There is no server authority:
every peer runs its own copy of the rules and can lie (position, score,
anything). Peers also learn each other's IP addresses during ICE. P2P is for
co-op and casual play among people who choose to play together — never for
competitive ranking, cheat-sensitive, or anonymous-stranger matchmaking.
Competitive or cheat-sensitive play is not supported in this template: push
back in product terms rather than shipping it on P2P.
Practical limits: a full mesh is O(N²) connections — cap rooms at ~8 peers.
Roughly 10–20% of peer pairs sit behind strict NATs and cannot connect; the
kit surfaces this per peer as connectionState: "failed" — show it in the
UI rather than hanging.
Schema — nothing to do by default
The reference relay above creates its two tables on first use
(CREATE TABLE IF NOT EXISTS, once per process) — nothing ships in
migrations/ and the template itself never touches your database. If you'd rather own or extend the schema (extra columns, your own
migration ordering), copy this into one of your app migrations; IF NOT EXISTS makes the runtime ensure and your migration coexist safely:
Setup — create the signaling relay (once)
Copy this file as-is (or adapt it — it is yours, not part of the kit):
import { z } from "zod";
import { getSql, type Sql } from "@/lib/db";
import type { PeerRow, RtcPollResponse, SignalRow } from "./p2p";
const ID = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const signalSchema = z.object({
op: z.literal("signal"),
room: ID,
from: ID,
to: ID,
kind: z.enum(["offer", "answer", "ice"]),
: z.().( v !== && .(v). <= , {
: ,
}),
});
leaveSchema = z.({ : z.(), : , : });
postSchema = z.(, [signalSchema, leaveSchema]);
= ;
= ;
globalRef = globalThis globalThis & {
?: <>;
};
(): <> {
globalRef. ??= ( () => {
sql.(
,
);
sql.(
,
);
sql.(
,
);
})().( {
globalRef. = ;
err;
});
globalRef.;
}
(): <[]> {
rows = sql.<{ : ; : }>(
,
[room, ],
);
rows.( ({ : r., : r. }));
}
() {
sql.(
,
[room, peer, name],
);
}
() {
.([
sql.(, [
,
]),
sql.(, [
,
]),
]);
}
(): {
(.(body), {
status,
: { : , : },
});
}
(): <> {
parsed = z
.({
: ,
: ,
: z.().().(),
: z..().().().(),
})
.({
: url..(),
: url..(),
: url..() ?? ,
: url..() ?? ,
});
(!parsed.) ({ : }, );
{ room, peer, name, since } = parsed.;
sql = ();
(sql);
(since === || .() < ) (sql);
(sql, room, peer, name);
rows = sql.<{
: ;
: ;
: [];
: ;
}>(
,
[room, peer, since],
);
: = {
: (sql, room),
: rows.( ({
: r.,
: r.,
: r.,
: r.,
})),
};
(body);
}
(): <> {
: ;
{
body = request.();
} {
({ : }, );
}
parsed = postSchema.(body);
(!parsed.) ({ : }, );
msg = parsed.;
sql = ();
(sql);
(msg. === ) {
sql.(
,
[msg., msg., msg., msg., .(msg.)],
);
} {
sql.(, [
msg.,
msg.,
]);
}
({ : });
}
(): <> {
{
(request. === ) ( (request.));
(request. === ) (request);
({ : }, );
} (error) {
.(, error);
({ : }, );
}
}
Setup — mount the API route (once)
import { createFileRoute } from "@tanstack/react-router";
import { handleSignaling } from "@/lib/multiplayer/signaling.server";
const handle = ({ request }: { request: Request }) => handleSignaling(request);
export const Route = createFileRoute("/api/rtc")({
server: { handlers: { GET: handle, POST: handle } },
});
Using the primitive
P2PRoom is framework-free, and a "room" is just a rendezvous key — a lobby
code, a 1:1 call id, a shared-document id, any string (≤64 chars). Any
architecture sits on top of the same three calls:
import { P2PRoom } from "@/lib/multiplayer";
const p2p = new P2PRoom({
room: "doc-42",
selfId: myId,
name: "ani",
onPeersChanged: (peers) => render(peers),
onMessage: (from, data, channel) => apply(from, data, channel),
});
await p2p.join();
p2p.broadcast(state);
p2p.send(event, to);
p2p.close();
React room binding (optional — copy if it fits your app)
For the common "everyone on this app plays together" shape, copy this hook to
src/lib/multiplayer/use-p2p-room.ts and adapt it freely — it is yours, not
part of the kit:
import { useCallback, useEffect, useRef, useState } from "react";
import { P2PRoom, type PeerInfo } from "./p2p";
export interface UseP2PRoomOptions {
room?: string;
name?: string;
}
export interface P2PRoomHandle {
selfId: string;
room: string;
peers: PeerInfo[];
joined: boolean;
broadcast: (data: unknown) => void;
send: (data: unknown, peerId?: string) => void;
: ;
}
(): {
( === ) ;
.(, );
}
(): P2PRoomHandle {
[selfId] = ( );
[room] = ( options. ?? ());
[name] = ( options. ?? selfId);
[peers, setPeers] = useState<[]>([]);
[joined, setJoined] = ();
roomRef = useRef<P2PRoom | >();
listeners = (
< >(),
);
( {
p2p = ({
room,
selfId,
name,
: setPeers,
: {
( fn listeners.) (, data, channel);
},
: (),
});
roomRef. = p2p;
p2p.();
{
roomRef. = ;
p2p.();
};
}, [room, selfId, name]);
broadcast = ( roomRef.?.(data), []);
send = (
roomRef.?.(data, peerId),
[],
);
onMessage = (
{
listeners..(fn);
{
listeners..(fn);
};
},
[],
);
{ selfId, room, peers, joined, broadcast, send, onMessage };
}
Used in a component:
import { useP2PRoom } from "@/lib/multiplayer";
function Game() {
const p2p = useP2PRoom({ name: "ani" });
const [positions, setPositions] = useState<Record<string, Pos>>({});
useEffect(
() =>
p2p.onMessage((from, data, channel) => {
if (channel === "state") {
setPositions((p) => ({ ...p, [from]: data as Pos }));
}
}),
[p2p.onMessage],
);
useEffect(() => {
let raf = 0;
let lastSent = 0;
const loop = (now: number) => {
if (now - lastSent >= 50) {
p2p.broadcast(myPositionRef.current);
lastSent = now;
}
raf = (loop);
};
raf = (loop);
(raf);
}, [p2p.]);
= () => p2p.({ : text });
;
}
Patterns:
broadcast() = unreliable/unordered, for continuously-refreshed state
(positions, cursors). send() = reliable/ordered, for events that must
arrive exactly once. Never stream game-rate state on send().
- Late joiners know nothing: on a new peer appearing in
p2p.peers, an
existing peer should send() it the current shared state. Exactly one
peer must answer: compare ids among the peers that were ALREADY in the
room (your selfId plus p2p.peers minus the newcomer) and answer only
if your selfId is the smallest — so two simultaneous joiners neither
double-answer nor go unanswered.
- Room ids: omit for "everyone on this app plays together"; pass
room: code for private lobbies (generate a short code, put it in the URL).
- Peers disappear without goodbye (tab close, sleep): treat a peer missing
from
p2p.peers as gone and drop its entities.
- The React binding above captures
room/name on first render — changing
them later requires remounting the component (key it on the room code).
Diagnostics
Each entry in p2p.peers carries connectionState, rttMs (data-channel
ping), and candidateType (host/srflx = direct). Optional env (set in
.env): VITE_STUN_URLS (comma-separated) to override STUN.