v0peer-multiplayer-nat-traversal

The router keeps the guest list: closing v0peer's NAT gap

Multiplayer in v0peer already knows who is nearby. It does not yet know how to walk through a router that only remembers outbound traffic. Geography membership, coarse positions, and WebRTC signaling are live. The missing piece is a relay with a key that expires. Until that relay exists, two people can share a session and still never share a data channel or a voice path.

Two people share one room. A coat-check ticket and a turnstile stand in for the host and the NAT that blocks the direct path.


v0peer multiplayer is a proximity graph, not a room that the server simulates. A human joins a session, the host remembers them, and each browser opens a WebRTC peer connection to a handful of neighbors. Poses and headings travel on a data channel. Voice, when two people actually talk, travels on a second peer connection that never shares that channel. The host's job is to introduce them.

That introduction works. The packets that should follow it often do not.

A home or carrier router gives every laptop and phone a private address and one public address for the building. Traffic leaving the building is written into a guest list: this inside address asked for that outside address, so the reply may come back. Traffic that arrives with no matching line has nowhere to go. WebRTC's answer to that guest list is ICE. Each peer gathers candidates — its own private address, the public address a STUN server sees, and, when those two cannot meet, a relay address on a TURN server — and the two browsers try pairs until one connects.

Today the clients gather the first two and usually skip the third.

What already ships

Three clients build the same mesh. World 2, @agent-play/play-ui, and the watch UI's vendored copy all construct an RTCPeerConnection from the same default: Google's STUN server at stun:stun.l.google.com:19302, plus a TURN URL only when VITE_GEOGRAPHY_TURN_URL is set at build time. The username and credential, when present, are the other two VITE_GEOGRAPHY_TURN_* variables.

The host, in @agent-play/web-ui, is the introduction desk.

Membership is POST /geography/membership with the session sid. The store caps a session at 100 humans. A join returns neighbors. Coarse position is POST /geography/coarse, at most once a second, and the neighbor set is cut to 16. Those 16 edges are the mesh. Each edge is its own peer connection, not a single socket for the whole session.

Signaling is two narrow doors. POST /geography/signal accepts offer, answer, or ice only when both human ids are members. POST /peer-call/signal accepts the same three kinds only when the call is active and both ids are parties to it. Neither route forwards the body to the other browser by itself. The store publishes it — geography signals through publishGeographySignalFanout, call signals as WORLD_PEER_CALL_SIGNAL_EVENT — and Redis carries it onto the session's SSE stream. The receiving browser ignores any signal whose toHumanId is not itself.

The geography data channel is named geography-yjs and is ordered. The lexicographically smaller human id is the polite peer: it creates the channel and the offer. When the channel opens, the peers run a Yjs sync step and then ship pose and heading updates. Remote avatars on screen are whatever that document contains.

Voice is a different RTCPeerConnection. The geography plan is explicit that microphones stay off the Yjs channel, and both World 2 human-reach and play-ui peer voice follow that split. A talk session can be billed and marked active on the host while its media path is still just two browsers trying to find each other.

One failure mode is already coded, and it does not add a new path. If connectionState becomes failed, the mesh closes that peer and calls ensurePeer again with the same ICE servers. The retry repeats the guest list. It does not invent a hallway.

The gap

Coarse presence and smooth presence are different pipes, and only one of them crosses NAT.

Membership and the 1 Hz coarse update are ordinary HTTPS to the host. They work from a phone on a carrier-grade NAT, a laptop behind an office firewall, and a desktop on a home router. The session looks populated. Neighbor ids arrive. Dots move, slowly.

The smooth pose, the heading, and the voice do not use that pipe. They need a candidate pair. STUN asks a public server what address and port the internet sees, and both sides try to punch a hole toward that answer. The punch lands often enough, on ordinary home routers, that a demo between two offices looks finished.

It fails when both sides are behind a NAT that issues a new public port for every destination, which is the normal behavior of many mobile networks and a lot of enterprise gear. Each browser learns a public address that only works for the STUN server it just talked to. The packet it then sends toward the other browser arrives at a router with no guest-list line for that pair. ICE concludes there is no route. The data channel never opens, so the Yjs document on each side keeps its local human and never converges. Voice has no second route at all: an active call with no candidate pair is silence.

The optional TURN variables do not close this. They are compiled into the JavaScript bundle. A long-lived username and password in a Vite env value are a public relay key. Anyone who can load the client can send their own traffic through that server. Production should not ship that key. The variables are a local escape hatch.

There is a second seam the repair has to hit on purpose. Geography mesh sessions in World 2 and play-ui already take an iceServers option and fall back to the env default. play-ui's peer voice session does the same. World 2's createHumanReachVoice does not. It always builds the peer connection from the env helper. A credential returned by the API will not reach human-reach voice until that function accepts the list.

v0peer-turn is the relay we are building. The server under test already allocates a UDP relay port, checks permissions, binds channels, and signs a short-lived nonce. Its login is still one long-term username, realm, and password on the process. That is the same class of key we must not compile into the client. Nothing in the join or call responses returns iceServers, and production browsers do not talk to this process yet.

The repair

Keep the host as the coat check. It holds the tickets — offers, answers, candidates — and the coat stays on the hook. Pose bytes and audio stay on the peer connection. The relay in the diagram is a TURN process on a small VPS with its own public address. coturn is the proven fill for that box. v0peer-turn can fill it once a join mints a credential instead of sharing one password. A tunnel or a serverless function cannot do this job: TURN allocation needs a stable public IP and a wide UDP range that the relay chooses from.

Run one relay for both meshes and both voice paths. On coturn, the ports are:

Port Use
3478/udp and 3478/tcp STUN and TURN
5349/tcp TURN over TLS
443/tcp Fallback where 3478 is blocked
49152–65535/udp Relay allocations

Turn on use-auth-secret. The host and coturn share one static secret. That secret stays on the server. The browser receives a username of the form <expiry-unix>:<humanId> and a credential that is the HMAC of that username. Coturn accepts the pair until the expiry. An hour is enough for a join and a call. A leaked page of JavaScript expires on its own.

Mint that pair in two places, because the two peer connections are created at two moments. Geography join already returns neighbors; add iceServers there, and again on rejoin. Peer-call start, when the call becomes active, returns a fresh pair for the voice connection. Both lists look the same: Google STUN first, then TURN urls for UDP and TLS, with the short-lived username and credential. The browser gathers host and server-reflexive candidates, tries a direct pair, and allocates a relay only when the direct pairs fail. People who can already see each other never touch coturn. People who cannot still get a geography-yjs channel and a voice connection.

Wire the list through the option that already exists on the mesh sessions and on play-ui voice. Add the same option to createHumanReachVoice and pass it into RTCPeerConnection. Leave the Vite variables as a developer override for a laptop pointing at a local coturn. Production clients should prefer the list the host just signed.

Do not add a fallback that copies Yjs updates or audio frames through /geography/signal or /peer-call/signal when ICE fails. Those routes are membership-checked introductions. A failed peer connection should be rebuilt with the relay-capable server list.

The path to follow

v0peer multiplayer architecture. Peers talk HTTPS and SSE with the Agent Play host. ICE then chooses a direct pair, a STUN reflexive address, or the coturn relay.

Signaling and media are different arrows on purpose. The arrows into the host are HTTPS. The arrows back are offers, answers, and ICE candidates on the session SSE stream. The direct pair between peers is the data channel or the voice track. The coturn card replaces that direct pair only when the browsers cannot punch a hole. Coarse position never enters either peer connection.

Sequence for a geography pair: join, signal through Redis SSE, then a direct geography-yjs channel or a coturn relay. Voice repeats the sequence on the peer-call route.

A voice call does not reuse the geography peer connection. It repeats this sequence on /peer-call/signal, with a credential minted when the call becomes active, and the bytes on the far side of ICE are audio rather than Yjs updates.

Outcomes to hold the repair to

These are the results the change is for. They are not measurements from a deployed relay, because that relay is not up.

A player behind a strict NAT still appears in membership immediately, still moves on the 1 Hz coarse pipe, and then becomes a smooth remote pose once the data channel opens. The channel opens through a direct candidate when one exists, and through coturn when it does not. A human-reach or peer-voice call that the host has already marked active produces audio on that same split: direct first, relay when the guest list has no line for the pair. Restarting the page after the credential expires causes a new join or a new call to mint a new pair. The old credential stops allocating.

The host's request volume for signals stays what it is now. Relay bandwidth scales with the edges that fail the direct attempt, and with voice calls that fail it, not with all 100 members and not with every one of the 16 possible neighbors. A session of people on the same network should show almost no TURN allocations. A session of phones on carrier networks should show them, and those allocations should end when the peer connection closes.

The HMAC secret lives only on the host and on coturn. Loading the World 2 bundle or the watch UI does not reveal a reusable relay password. A credential names one human and one expiry. Coturn rejects it after that expiry without a deploy.

The observation that tells us the repair is real, once coturn is in front of a session, is the selected candidate pair. Log host, srflx, or relay when the geography data channel opens and when the voice connection reaches connected, plus the time from join to channel open and from call start to connected audio. A repair that only works in the lab will show srflx on office networks and timeouts on phones. A repair that relays everyone will show relay on networks that used to connect directly, and the bandwidth graph will say so. We do not have those numbers yet. They are the acceptance check.

What stays is the product shape. The session store remains authoritative for who is in the geography and who is in a call. Neighbors stay a proximity cut of 16 inside a cap of 100. Yjs stays on geography-yjs. Microphones stay on their own peer connection. Failed ICE is still not a reason to stream the world through the API.

What the team builds next

Put the relay on a public VPS with the port map above. On coturn, turn on use-auth-secret. On v0peer-turn, stop treating config.password as the client secret and mint a short-lived username the same way. Teach membership join, rejoin, and peer-call start to return iceServers. Thread that list into every RTCPeerConnection the mesh and the voice paths create, including createHumanReachVoice. Keep the Vite variables as a local override. Then watch candidate-pair types across a direct network and a strict NAT before treating multiplayer as closed.

The guest list on the router can stay as it is. We stop depending on it to remember strangers.


Images for upload

Role Path
Post meta / cover docs/blog/agent-play-multiplayer-nat-guest-list.png
Content — architecture docs/blog/v0peer-multiplayer-architecture.png
Content — sequence docs/blog/v0peer-multiplayer-ice-sequence.png