> ## Documentation index
> Fetch the complete documentation index at: https://rustynes-git-release-v3-6-0-aaf8d1.docs7.io/llms.txt
> Ask it with GET https://rustynes-git-release-v3-6-0-aaf8d1.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Netplay webrtc

# Netplay: NAT traversal (STUN) + WebRTC / browser transport

**Status:** shipped in v1.0.0 — **deploy bundle + wasm lobby landed; the hosted
stack is deployment-ready, live verification pending the maintainer's hosted
run.** On top of the signaling/transport skeleton, the browser path is
**deployable + usable**: a turn-key `deploy/` Docker bundle (signaling server +
Caddy TLS proxy + coturn STUN/TURN, all per-deploy values via `.env`), a
configurable signaling URL + ICE/STUN list (`[netplay] signaling_url` /
`stun_servers`, plumbed into `BrowserNetplay::connect`), and a wired wasm **lobby
UI** that drives the `RollbackSession` over WebRTC per rAF frame. The remaining
gap is a live end-to-end browser session, which needs the deployed signaling
server **running** + real browsers and **cannot be verified headlessly** — it is
the maintainer's manual step (checklist in `deploy/README.md`), and is **not**
claimed verified here. This file is the spec for those pieces plus the
STUN/hole-punch scaffold they build on.

Also landed: the N-peer UDP roster handshake (3-4 player native UDP mesh,
loopback-verified); the reference signaling server (a deployable WebSocket relay
behind a non-default feature); and the wasm-frontend WebRTC wiring
(compile-verified).

**References:** RFC 5389 (STUN), RFC 8445 (ICE), the WebRTC data-channel API
(`RtcPeerConnection` / `RtcDataChannel`), and the existing transport-agnostic
session core in `crates/rustynes-netplay` (`docs/`-adjacent — see the crate rustdoc).

---

## 1. Where this fits

The rollback session (`RollbackSession<T: Transport>`) is **transport-agnostic**:
it only ever `send`s and `poll`s `NetMessage`s. The base `Transport` is native UDP
(`UdpTransport`); the two pieces a real internet deployment needs are also present:

| Piece | Crate location | State |
|---|---|---|
| STUN client (public-addr discovery) | `rustynes-netplay::stun` | Implemented + unit-tested; live round-trip `#[ignore]`d |
| UDP hole-punch state machine | `rustynes-netplay::stun::HolePunch` | Implemented + unit-tested |
| N-peer UDP roster handshake (3-4 players) | `rustynes-netplay::mesh_net` | Implemented + loopback-verified |
| WebRTC data-channel transport (browser) | `rustynes-netplay::webrtc::{WebRtcTransport, WebRtcMeshTransport}` (wasm-only) | Compile-verified; 2-player + N-peer mesh transports |
| Signaling server + offer/answer/ICE | `crates/rustynes-netplay/examples/signaling_server.rs` | Implemented (reference WS relay, `--features signaling-server`) |
| N-peer browser mesh signaling (2-4 players) | `rustynes-netplay::signaling` (slot-routed offer/answer/candidate) | Implemented + unit-tested for 2/3/4-peer rooms |
| Wasm-frontend netplay wiring + lobby UI | `rustynes-frontend` (`wasm_netplay.rs`, `wasm_lobby.rs`) | Wired + compile-verified for 2-4 player mesh; browser session pending a live deploy |
| Deploy bundle (signaling + TLS + STUN/TURN) | `deploy/` (Dockerfile + compose + Caddy + coturn + `.env.example`) | Turn-key + deployment-ready (builds); live session pending the maintainer's hosted run |
| Configurable signaling URL + ICE/STUN list | `[netplay] signaling_url` / `stun_servers` | Shipped |

Nothing here touches the emulator core, so the determinism contract and the
single-player path are unaffected. AccuracyCoin stays 100.00% and the commercial
oracles byte-identical.

---

## 2. NAT traversal (native UDP)

### 2.1 STUN discovery

A peer behind a home NAT does not know the public `IP:port` its router presents
to the internet. STUN (RFC 5389) solves this: the peer sends a **Binding
Request** from its game UDP socket to a public STUN server; the server replies
with a **Binding Success Response** whose **XOR-MAPPED-ADDRESS** attribute is the
source address the server observed — i.e. this peer's public mapping.

`rustynes-netplay::stun` implements exactly this:

- `build_binding_request(rng) -> (bytes, transaction_id)` — the 20-byte header
  (type `0x0001`, magic cookie `0x2112A442`, a fresh random 96-bit transaction
  id, zero attributes).
- `parse_binding_response(buf, expected_tx) -> Option<SocketAddr>` — validates
  type/cookie/length/transaction-id and decodes XOR-MAPPED-ADDRESS (`0x0020`),
  falling back to the deprecated MAPPED-ADDRESS (`0x0001`). Malformed / short /
  wrong-cookie / wrong-id / non-success buffers return `None` (never panic).
  IPv4 and IPv6 are both handled (X-Port = port XOR high-16 of the cookie;
  X-Address = address XOR cookie, plus the transaction id for IPv6).
- `StunClient` (native) — `discover(server, timeout)` drives the round-trip on a
  bound `UdpSocket` and returns the public `SocketAddr`. For a socket **shared**
  with the live `UdpTransport`, use the non-blocking `send_request` +
  `parse_binding_response` (with `last_transaction_id()`) so STUN and game
  traffic share one drain.

**Recommended public servers** (resolved at run time, never hardcode an IP):
`stun.l.google.com:19302`, `stun1.l.google.com:19302`. A production deployment
should run its own (e.g. `coturn`) to avoid third-party rate limits.

**Manual verification** (the `#[ignore]`d probe):

```text
cargo test -p rustynes-netplay --test stun_probe -- --ignored --nocapture
```

### 2.2 UDP hole punching

Once each peer knows its own public address, the two public addresses are
**exchanged out of band** (see §3 — through a signaling server, or in a LAN/test
setup, manually). Then both peers send packets at each other's public address
*simultaneously*: the first outbound packet from each side opens its own NAT's
mapping, so the peer's matching packet then traverses it. A `Sync` packet is a
fine punch packet — it doubles as the existing handshake.

`HolePunch` models this without doing any I/O (so it is portable + unit-tested):

```text
Discovering ──(both public addrs known)──▶ Punching ──(peer's packet received)──▶ Connected
```

- `local_discovered(addr)` — record our STUN result.
- `peer_discovered(addr)` — record the peer's (from signaling); advances to
  `Punching` once both are known.
- `should_punch()` — true while `Punching`; the caller sends punch packets at
  `peer_public()`.
- `punch_received(from)` — a packet from the peer's known public address
  advances to `Connected` (a stray source is ignored — no hijack).

The caller then points the live `UdpTransport`'s remote at `peer_public()` (via
`UdpTransport::set_remote`) and runs the normal `NetplayConnection` handshake +
`RollbackSession`.

### 2.3 Pending (native NAT)

- **Real cross-NAT traversal** needs a reachable STUN server and two real NATs —
  not reproducible in CI/offline, hence the `#[ignore]`d probe.
- **Symmetric NATs** (which assign a different external port per destination)
  defeat basic hole punching; the fallback is a **TURN relay** (RFC 8656). The
  TURN *client* + the relay-transport hand-off are now wired (§2.5, `relay.rs` +
  `UdpTransport::from_relay`, mock-TURN-verified by `relay_loopback`); the only
  remaining item is a **live coturn + two real symmetric-NAT devices** verify
  (see §5).
- **Plumbing** `HolePunch` into `NetplayConnection` end to end (discover →
  exchange → punch → handshake as one flow) **landed in v1.8.7** — the
  `NatConnect` orchestrator (§2.5). The cone-NAT path is end-to-end and
  loopback/mock-verified in CI; live cross-NAT play is still the maintainer's
  manual run (see §5).

### 2.4 N-peer UDP roster handshake

The 2-player UDP path is **point-to-point**: the host adopts a single joiner and
the two exchange input directly. For **3-4 players** every peer must reach every
*other* peer — a **fully-connected mesh** — and a joiner cannot learn the *other*
joiners' addresses by itself (it only ever talks to the host during its
handshake). A host-distributed **roster** closes that gap.

`rustynes-netplay::mesh_net` adds three pieces:

- **`UdpMeshTransport`** — the UDP analogue of the in-memory `MeshTransport`. One
  bound socket plus a table of every *other* peer's `(player, SocketAddr)`;
  `send` fans a `NetMessage` out to all of them, `poll` drains the socket and
  attributes each datagram to its sender. Foreign / malformed datagrams are
  dropped (never panic).
- **`MeshHost`** — listens, adopts up to `num_players - 1` joiners from their
  `Sync`s (assigning each the lowest free player index), then broadcasts the
  full **`NetMessage::Roster`** — every peer's `SocketAddr`, player index and
  (protocol 9) key share — to all joiners. The roster is **re-sent a few
  times** for UDP loss tolerance. While it gathers (v3.4.0) it answers each
  adopted joiner's `Sync` with an accepted zero-share `Sync`, and drops a
  joiner it has not heard for 3 s.
- **`MeshJoiner`** — dials the host, `Sync`s, waits for the roster, then builds
  its own `UdpMeshTransport` wired to the host **and every other joiner**,
  skipping its own entry. Since protocol 9 it identifies its own entry by its
  own **key share**, which the host copied from the joiner's `Sync` into the
  roster: that works behind a NAT, where the address the host lists for the
  joiner is a rewritten one the joiner cannot observe. (Before protocol 9 it
  matched its bound source address, which only works on loopback / LAN, and
  fell back to an index chosen out of band; since v3.4.0 a roster that does
  not carry the joiner's own share is ignored instead of falling back,
  `a_roster_without_our_key_share_is_ignored`.) It sends to the host at the
  address it dialed. Once the host has answered that it is gathering, it waits
  while the host keeps answering, up to its gather timeout
  (`docs/netplay-session.md` section 2.1).

**Protocol version.** `PROTOCOL_VERSION` is bumped **2 → 3** for the new
`NetMessage::Roster` variant. An older (v2) peer's `from_bytes` rejects the
unknown message tag cleanly (returns `None`), so a v2 peer **drops** a v3
`Roster` rather than mis-parsing it. The roster is bounded to **4 entries**
(`NetMessage::MAX_ROSTER`); an oversized or otherwise malformed roster decodes to
`None` — no unbounded allocation on hostile input.

**Robustness.** Malformed / foreign / duplicate datagrams are dropped and never
panic. A duplicate `Sync` from an already-adopted joiner is **idempotent** (it
does not shift player indices). A `Sync` carrying a mismatched ROM hash is
rejected, and from v2.9.8 (`PROTOCOL_VERSION` 5) so is one for the same ROM on
a differently configured machine: `Sync` carries a `SessionIdentity`, the ROM
hash plus `rustynes_core::config_digest` (SHA-256 over the region, the parsed
cartridge board and every `HardwareOptions` field), and a difference fails with
`MeshError::ConfigMismatch` / `DisconnectReason::ConfigMismatch` /
`NetplayError::ConfigMismatch`. Peers refuse rather than adopt the host's
options (ADR 0044). From v3.0.0 (`PROTOCOL_VERSION` 6, magic `"RNE6"`, ADR
0045) the identity also carries the emulation epoch. Another epoch, or an
older RustyNES's `Sync` (`"RNES"`, `"RNE5"`, decoded at exactly its own length
and magic, since v3.1.0's protocol 7 / `"RNE7"` also v3.0.x's `"RNE6"`, and since
v3.2.0's protocol 8 / `"RNE8"` also v3.1.x's `"RNE7"`, each of which names its
epoch), fails with `MeshError::EmulatorMismatch` and its counterparts, so
a mixed-version session is refused with a reason instead of timing out.
**Desktop (v3.4.0).** The desktop netplay panel reaches this mesh: a host
that picks 3 or 4 players gathers its joiners through `MeshHost`, and every
joiner joins through `MeshJoiner`, which completes against a 2-player host as
player 1 of 2 and against a mesh host as the player the roster gives it, so
the joiner does not choose. Each joiner sends to the host at the address it
dialed (a host bound to `0.0.0.0` has no better one to advertise) and finds
its own roster entry by its key share. Since protocol 9 an `InputAck` names
the acknowledging player and each peer resends to the least-acknowledged one:
an ack that named no one let one joiner's ack stop another's retransmission to
a host that had not received it, and the late-starting host stalled.
Since v3.4.0's protocol 9 (`"RNE9"`, ADR 0046) the `Sync` also carries a
16-byte key share, a `Roster` entry carries each peer's share, and every
native datagram after the handshake is sealed with a per-session HMAC key
(`docs/netplay-session.md` section 2); a v3.2.x/v3.3.x `"RNE8"` peer is
refused naming its epoch.
**Only a v3.0.0-or-later peer can give that reason.** A v2.9.9 or older peer
ignores the `"RNE6"` magic as a foreign datagram and still times out on its
side. Between two protocol-6 peers both sides name it: a mesh host answers a
refused joiner's `Sync` with its own before returning the error, so the
joiner reports `EmulatorMismatch` (or the ROM / configuration mismatch) as
well (`epoch_mismatch_is_named_at_both_ends`). Signaling rooms still match by
ROM only.
**Who may refuse (v3.4.0).** The handshake is unsealed, so a refusing `Sync`
counts only from the peer it concerns. A joiner acts on one only from the host
it dialed, and a `NetplayConnection` with a known remote only from that remote;
anything else is ignored (`a_joiner_ignores_a_refusal_not_from_its_host`,
`a_dialing_connection_ignores_a_refusal_from_a_stranger`). A mesh host always
answers the refused sender with its own identity, but ends the gathering only
while it has adopted nobody: once a joiner is waiting, one mismatched (or
forged) `Sync` would drop it, so the host keeps gathering and its timeout
reports the refusal it saw instead of a bare timeout
(`a_refusal_after_adoption_keeps_the_gathering`,
`a_timed_out_gathering_names_the_refusal_it_saw`). A 2-player listening host,
which has adopted nobody, still refuses whoever dialed it.

**Verification.** The loopback integration test `tests/mesh_udp.rs` stands up a
host + 2-3 joiners on `127.0.0.1` ephemeral ports, completes the multi-joiner
handshake, exchanges the roster, runs ~120 frames of N-player input over the
**real UDP mesh**, and asserts every peer's confirmed gameplay digest equals
each other *and* a single no-rollback reference run (Four Score on for >2
players). This is the same proof shape as the in-memory
`n_player_rollback_matches_reference` determinism test, but over real sockets.

### 2.5 Native UDP rendezvous (mobile) — the room-code / CGNAT path (v1.8.7)

§2.1–§2.2 give the *pieces* (STUN discovery + the hole-punch state machine);
§2.3 listed "plumb them into `NetplayConnection` end to end" as the small
follow-up. v1.8.7 lands that orchestrator so a mobile (or any native) peer can
host/join an **internet** match by sharing a short **room code** — the path that
matters for Android, where two phones are typically behind **carrier-grade NAT
(CGNAT)** and cannot exchange addresses by hand.

**`NatConnect` — the steppable pump.** `rustynes-netplay::nat_connect`
(native-only, gated behind the `netplay-client` feature) wires the isolated
building blocks into one steppable flow. Each `pump()` call advances one step and
returns the current `NatPhase`; STUN discovery and TURN allocation do
read-timeout-bounded *blocking* probes (sub-second), not strictly non-blocking
I/O. The caller drives it once per tick (the mobile bridge does this inside
`np_advance_frame`) until `Synced`,
then takes the ready `NetplayConnection` with `into_connection()`:

```text
Registering ─ connect to signaling + Join/host the room ──────────▶ Discovering
Discovering ─ STUN: learn our public reflexive addr (§2.1) ───────▶ Exchanging
Exchanging  ─ send/receive PublicAddr over signaling ─────────────▶ Punching
Punching    ─ send Sync packets at the peer's public addr (§2.2) ─▶ Synced
              └─ (symmetric NAT: punch times out, TURN configured) ▶ Relaying ─▶ Synced
```

Key properties:

- **One socket throughout.** The same bound `UdpSocket` carries the STUN probe,
  the punch packets, *and* the eventual gameplay transport — so the public
  mapping the peer learns is exactly the mapping gameplay flows over. During the
  bounded STUN probe — and again during the bounded TURN allocate + permission —
  the socket is briefly made blocking, then restored to non-blocking for the
  punch / gameplay path.
- **Deterministic, look-alike-free room codes.** `host()` returns a 6-char code
  from a SplitMix64-seeded alphabet that omits `0/O/1/I/L` (so a verbally-shared
  code is unambiguous). The seed also drives the STUN transaction ids; the mobile
  bridge seeds it non-deterministically per session so two concurrent hosts never
  collide on a code. This is host-side orchestration only — it never touches the
  emulator's determinism contract (the ROM + input + core seed are untouched).
- **It hands off to the existing session unchanged.** Once `Synced`,
  `into_connection()` builds a `UdpTransport` fixed at the peer's punched public
  address and runs the normal `NetplayConnection` handshake; a standard
  `RollbackSession<UdpTransport>` then drives the match exactly as the LAN /
  direct-IP path does. The 2-player room-code path completes the first joiner
  (mirroring the direct-IP `np_host`).

**The `PublicAddr` signaling extension — one relay, two rendezvous shapes.**
The address exchange in the `Exchanging` phase rides the **same signaling relay**
that brokers the browser SDP/ICE handshake (§3.2). A new
`SignalMessage::PublicAddr { from, to, addr }` variant carries a single
`IP:port` string (a `SocketAddr` rendered to text) from one slot to another.
Unlike `Offer` / `Answer` / `Candidate` (which carry browser WebRTC SDP/ICE),
`PublicAddr` carries the raw reflexive address the native client feeds to
`HolePunch::peer_discovered`. The relay routes it **by slot exactly like the SDP
messages** — `signaling::Relay` already had the room bookkeeping + slot routing,
so the same deployed server serves *both* the browser path and the mobile
native-UDP path with no new service. The mobile **signaling client**
(`signaling_client.rs`) is a small blocking-worker `tungstenite` WebSocket client
behind the `netplay-client` feature (no tokio; it mirrors the
`rustynes-cheevos/http.rs` blocking-worker shape), so it slots into the
single-threaded mobile tick without an async runtime.

**TURN relay fallback (`relay.rs`) — with its transport hand-off now wired.** A
**symmetric NAT** assigns a different external port per destination, which
defeats basic hole punching. When the punch times out *and* a TURN relay is
configured, `NatConnect` enters `Relaying`: `relay.rs` is an RFC 8656 TURN client
(long-term-credential `Allocate` → `XOR-RELAYED-ADDRESS`, `Send`/`Data`
indications) plus a `RelayUdpSocket` shim, and the orchestrator allocates a relay,
installs a permission for the peer, and exchanges the **relayed** address over the
same `PublicAddr` channel. Once both relayed addresses are exchanged,
`into_connection()` builds a relay-backed `UdpTransport` via
`UdpTransport::from_relay`, so live gameplay flows over the relay (§2.5 below).

Because `Allocate` and `CreatePermission` ride **unreliable UDP**, the client
**retransmits** each request every `RTO` (250 ms) until the caller's overall
timeout (5 s for `Allocate`, 2 s for `CreatePermission`), guided by RFC 5389 §7.2.1 (a fixed 250 ms RTO, not the RFC default 500 ms + exponential backoff) —
a single dropped request-or-response datagram must not hard-fail the transaction.
STUN/TURN requests are idempotent (the server re-answers a retransmit; a late
duplicate response is discarded by the transaction-id filter), so the recovery is
transparent. The unauthenticated 401-challenge round and the authenticated retry
are each their own retransmitted transaction. (The STUN discovery probe is
single-shot per pump but retried across pumps by `tick_discovering`, so it heals
a dropped datagram the same way.) The read-timeout expiry that ends each blocking
receive surfaces as `WouldBlock` on Unix and `TimedOut` on Windows; both are
treated identically (loop and retransmit, never fail).

> **Status — the relay-transport hand-off is wired and mock-TURN-verified;
> only a live coturn run is pending.** `#40` added `UdpTransport::from_relay`,
> so when both peers are symmetric-NAT-bound `NatConnect::into_connection` now
> builds a **relay-backed** `UdpTransport` over `RelayUdpSocket` and live
> gameplay flows over the TURN relay (the bridge reports `relayed = true` and the
> Android UI shows the "via relay" badge). The end-to-end relay path (allocate,
> permission, relayed-address exchange, and the gameplay transport) is exercised
> by the `relay_loopback` test against a mock TURN endpoint. The **only** remaining
> item is a **live verify with a real coturn server and two real symmetric-NAT
> devices**, which CI/offline cannot reproduce (see §5). Cone NAT (the common
> home/CGNAT-cone case) does not need the relay and works end-to-end regardless.

**Bridge + Android surface.** The mobile bridge (`rustynes-mobile`) exposes
`np_host_room(num_players, NpNetConfig) -> room code` and
`np_join_room(room_code, NpNetConfig)`. `NpNetConfig { stun_servers, turn_url,
turn_user, turn_secret, signaling_url }` is projected onto `NatConfig`: an empty
`stun_servers` falls back to the crate's `DEFAULT_STUN_SERVERS`
(`stun.l.google.com:19302` + `stun1`), and a TURN relay is configured only when
the URL resolves *and* both credentials are present (otherwise the session is
punch-or-fail / cone-NAT-only). The session begins in a new `Negotiating` phase
(surfaced by `np_status` with a short sub-step string — `"Discovering"`,
`"Exchanging"`, `"Punching"`, `"Relaying"`) and converges on the existing
`Connecting` → `InGame`. The Android UI is a create-and-share-a-room-code /
join-by-code flow with the endpoints overridable in Settings; it ships defaulting
to a **placeholder** relay URL (`wss://relay.rustynes.example/ws`) until the
maintainer hosts the `deploy/` stack (§3.4) and substitutes a real one.

---

## 3. WebRTC / browser transport

A browser cannot open a raw UDP socket, so the wasm netplay path uses WebRTC.

### 3.1 `WebRtcTransport` (implemented skeleton)

`rustynes-netplay::webrtc::WebRtcTransport` (wasm-only) implements the `Transport`
trait over an `RtcDataChannel`:

- Constructed from an **already-open** data channel configured **unreliable +
  unordered** (`RtcDataChannelInit` with `maxRetransmits = 0`, `ordered =
  false`) — the same lossy/out-of-order semantics rollback already tolerates,
  matching UDP.
- `send` → `data_channel.send_with_u8_array(&msg.to_bytes())`.
- `poll` drains an `Rc<RefCell<VecDeque<NetMessage>>>` that the channel's
  `onmessage` callback fills (binary type set to `arraybuffer`; each payload
  decoded with `NetMessage::from_bytes`, malformed dropped).

So a `RollbackSession` drives a browser peer with **no change** to the session
core — identical to how it drives a native UDP peer.

### 3.2 Signaling server (implemented)

A WebRTC peer connection forms only after the two browsers exchange connection
metadata through a third party. The **signaling server** is a small relay (a
WebSocket service) that brokers, per match, the standard WebRTC handshake:

1. **SDP offer** — the offerer creates an `RtcPeerConnection`, creates the data
   channel, calls `createOffer()` → `setLocalDescription(offer)`, and sends the
   offer SDP to the answerer via the server.
2. **SDP answer** — the answerer `setRemoteDescription(offer)`, `createAnswer()`
   → `setLocalDescription(answer)`, and sends the answer SDP back.
3. **ICE candidates** — as each side's ICE agent gathers candidates
   (`onicecandidate`), it forwards them through the server; the peer feeds each
   to `addIceCandidate()`. ICE (RFC 8445) is WebRTC's own STUN/TURN-based
   traversal — so for the browser path, ICE subsumes the §2 native
   STUN/hole-punch logic (configure the `RtcConfiguration` with `iceServers`
   pointing at a STUN/TURN server).
4. Once ICE connects, the data channel fires `onopen`; the app wraps it in
   `WebRtcTransport::new(channel)` and hands it to a `RollbackSession`.

The server only brokers the handshake; it carries **no gameplay traffic** (that
flows peer-to-peer over the data channel).

**Reference server.** `crates/rustynes-netplay/examples/signaling_server.rs`, behind
the **non-default** `signaling-server` cargo feature (so it never bloats the
core / wasm / workspace build). The routing logic is the pure, async-free
`rustynes_netplay::signaling::Relay` — room bookkeeping + the routing decision, no
I/O — which is **unit-tested headlessly in the default build**. The example bin
is just the async **tokio + tokio-tungstenite** WebSocket plumbing around it.

**Run:**

```text
cargo run -p rustynes-netplay --features signaling-server --example signaling_server
```

It listens on `127.0.0.1:9000` by default; override with one CLI arg (e.g.
`0.0.0.0:9000`).

**Deploy:** put it behind a **TLS-terminating reverse proxy** (nginx / Caddy) so
browsers reach it as `wss://...` — an `https` page cannot open a plain `ws://`.
It is stateless apart from its in-memory rooms, so run a single instance or a
room-affinity load balancer. Pair it with a **STUN/TURN** server (`coturn`) for
the actual NAT traversal; the signaling server only brokers the handshake and
carries no gameplay traffic.

**Wire format** (JSON over WebSocket text frames). This is generalized from
2 peers to an **N-peer mesh** (2..=4): `join` carries the room's `max_players`,
and `offer` / `answer` / `candidate` carry `{ from, to }` slots so the relay
routes each to a specific peer:

```text
client → join      { "room": "<code>", "rom_hash": "<hex>", "max_players": 4 }
server → joined     { "slot": N, "max_players": 4 }   (your slot + room size)
server → peer-joined{ "slot": M }                     (a higher-slot peer joined → offer to it)
peer  → offer       { "from": A, "to": B, "sdp": "..." }   (routed to slot B)
peer  → answer      { "from": B, "to": A, "sdp": "..." }   (routed to slot A)
peer  → candidate   { "from": A, "to": B, "candidate": "...", "sdp_mid": "...", "sdp_m_line_index": N }
server → peer-left  { "slot": M }                     (on a peer's disconnect)
server → error      { "reason": "<room-full | rom-mismatch>" }
```

The server assigns each joiner the **next free slot** (`0..max_players`), and the
rule is **the lower slot of any pair offers to the higher slot** — so when a
newcomer joins, every *existing* peer is sent `peer-joined { slot: newcomer }`
and offers to it. The relay routes `offer` / `answer` / `candidate` to the named
`to` slot (a legacy 2-peer client that omits `from`/`to`/`max_players` falls back
to 2 players + "the other peer" routing). **The relay never forwards a
client's own `from`:** it overwrites `from` with the sender's real slot (its
position in the room's membership list), and drops the frame from a client the
room does not list. Recipients index their per-peer state by `from` (the
browser mesh applies the SDP to peer connection `from`, the native NAT
rendezvous adopts `from` as the peer slot), and the WebSocket's room membership
is the only thing that authenticates a seat, so a client-chosen `from` would
let one member of a 3-4 player room present another seat's offer, answer,
candidate or public address. The rule is identical in the reference relay and
the hosted lobby, pinned by the conformance vectors. It verifies every peer in a room
announced the **same `rom_hash`** (`rom-mismatch` otherwise; `room-full` past
`max_players`). The pure relay logic is unit-tested for 2-, 3-, and 4-peer rooms
in `rustynes_netplay::signaling`.

A client is in **at most one room**. A `join` or `quick-match` from a client
that is already in a room first leaves that room exactly as a disconnect would
(its remaining peers get `peer-left`, and an emptied room is dropped), then
joins the new one (v2.9.2, audit AUD-13). Before v2.9.2 the relay only
re-pointed the client, which stayed in the old room's slots as a ghost: that
room could never empty, and its peers kept offering to a peer that had gone. A
client re-joining the room it is in likewise leaves and re-enters it, rather
than occupying two slots.

**Frame-number bound (players' session).** A received `Input` or `Checksum`
names a frame, and the rollback session's per-frame tables are grown to reach
it. `RollbackSession` drops any whose frame is past
`RollbackSession::max_accepted_frame` = current frame + `input_delay` +
`max_rollback_frames` + `MAX_SESSION_FRAME_LOOKAHEAD` (1024), so a peer cannot
choose an allocation size (v2.9.2, audit AUD-06; the derivation of the bound
is on the constant). The spectator session has carried the same 1024-frame
bound (`MAX_SPECTATOR_FRAME_LOOKAHEAD`) since it was written.

### 3.3 Wasm-frontend wiring + lobby (wired)

`rustynes-frontend` has a **wasm-only netplay path** (`wasm_netplay.rs`) that:

1. Opens a **WebSocket signaling client** (via `web-sys` `WebSocket`) to the
   configured signaling URL (§3.2). The URL is **configurable** —
   `BrowserNetplay::connect(signaling_url, room, ice_servers)` takes the
   `[netplay] signaling_url` + `stun_servers` from config (no longer a hardcoded
   STUN entry); an empty ICE list falls back to
   `rustynes_netplay::DEFAULT_STUN_SERVERS`.
2. Runs the **N-peer `RtcPeerConnection` offer/answer/ICE mesh handshake** over
   that socket — one peer connection per *other* player — yielding an open
   `RtcDataChannel` to each, configured `iceServers` from the list above.
3. Once **all `max_players - 1`** channels are open, bundles them into a
   `rustynes_netplay::WebRtcMeshTransport` (the browser analogue of the native
   `UdpMeshTransport`: `send` broadcasts to every peer, `poll` drains one merged
   inbox; each `NetMessage` carries its own `player` field so the session
   demultiplexes) and drives the existing `RollbackSession` from the **rAF frame
   loop** — `App::produce_one_frame` routes through
   `produce_one_frame_browser_netplay` while a browser session is active,
   mirroring the native `produce_one_frame_netplay` (single-player path
   byte-for-byte unchanged when inactive).

The **lobby UI** (`wasm_lobby.rs`) is a bounded egui overlay (in the `~`
debugger surface, wasm-only): a signaling-URL field (seeded from config), a room
/ lobby code, Host vs Join, a 2-4 player selector, Connect/Leave, and a status
line (connecting / in-game / error). It is the browser counterpart of the native
`debugger/netplay_panel.rs` (which stays a "native-only" note on wasm). Edits
emit a `LobbyRequest` the `App` drains each frame.

Both `wasm-winit` and `wasm-canvas` builds **compile** with the lobby wired.

**Honest scope.** The path is **wired + compile-verified** for 2-, 3-, and
4-player mesh sessions, and the pure N-peer signaling *protocol* is
unit-tested. A full browser session still needs the signaling server **running**
(see §3.4) plus **N real browsers / tabs**, which **cannot be verified
headlessly**. The lobby is a *functional* lobby, not a polished multi-screen UI.
The build gate is `cargo build -p rustynes-netplay --target wasm32-unknown-unknown`
plus the frontend's two wasm flavours compiling with the netplay + lobby
present.

### 3.4 Deploying the signaling + STUN/TURN stack

The `deploy/` directory is a **turn-key** bundle for the server side — a
maintainer can `docker compose up` on a host with a domain and get a working
signaling + STUN/TURN stack with no source edits (all per-deploy values come
from a `.env`). The **same** stack serves both the browser (SDP/ICE) and the
mobile (`PublicAddr`, §2.5) rendezvous — one relay, both clients. The mobile
client maps `.env` onto its `NpNetConfig` (`signaling_url` = `wss://<DOMAIN>`,
the TURN URL/creds from `TURN_*`, STUN default), as `deploy/README.md` lays out:

| File | Role |
|---|---|
| `deploy/Dockerfile` | Builds + runs the `rustynes-netplay` `signaling_server` example (`--features signaling-server`). |
| `deploy/docker-compose.yml` | Wires `signaling` + `caddy` (TLS → `wss://`) + `coturn` (STUN/TURN); coturn credential/realm injected from env. |
| `deploy/Caddyfile` | TLS termination + WebSocket-upgrade reverse proxy. |
| `deploy/turnserver.conf` | Minimal coturn STUN + TURN config (credential/realm come from env, not checked in). |
| `deploy/.env.example` | Template for `DOMAIN` + `TURN_*`; copy to `.env` (gitignored). |
| `deploy/README.md` | Full run/deploy steps + the manual verification checklist. |
| workspace-root `.dockerignore` | Keeps `target/`, ROMs, docs out of the image build context. |

**Local two-tab test:**

```text
cd deploy && DOMAIN=localhost docker compose up --build
```

Caddy serves `wss://localhost/` with an internal self-signed CA; coturn provides
STUN/TURN on `:3478`. Point the wasm build's `signaling_url` at `wss://localhost`
and open it in two tabs.

**Public deploy:** `cp deploy/.env.example deploy/.env`, set `DOMAIN` to a real
hostname (Caddy auto-provisions a Let's Encrypt cert — drop `tls internal` from
the `Caddyfile`), set a strong `TURN_SECRET` + `TURN_REALM`, and point
`[netplay] signaling_url` / `stun_servers` at your host. Full notes + the
**manual verification checklist** (2-tab → 2-machine → 4-player matrix + the
ops/DNS/TLS/TURN-bandwidth steps) live in `deploy/README.md`.

**No COOP/COEP needed.** The browser path uses a WebRTC `RtcDataChannel` plus an
`AudioWorklet` — neither needs `SharedArrayBuffer`, so the hosting page needs no
cross-origin-isolation (`Cross-Origin-Opener-Policy` / `-Embedder-Policy`)
headers, and the existing GitHub Pages deploy serves it unchanged.

**NAT / relay notes.** ICE (the browser's own STUN/TURN agent) subsumes the §2
native hole-punch logic for the WebRTC path. STUN alone traverses most home
(cone) NATs; **symmetric NATs** need the **TURN relay** (coturn) as a fallback —
which, unlike STUN, carries the media (here the data-channel) so it costs
bandwidth. Run your own coturn (in the bundle) rather than leaning on public STUN
for anything beyond a quick test.

### 3.5 The hosted lobby and TURN (v3.4.0, `T-HOSTED-NETPLAY`)

The maintainer also runs the stack as a service, so players need not host
anything: a Cloudflare Worker in `services/hosted/` (its `README.md` is the
deploy runbook and design record). It is an **alternative** to §3.4's bundle,
not a replacement; the reference server, the bundle and user-supplied URLs all
keep working, and a stock build does not know the hosted service exists.

**Lobby.** `wss://<hosted base>/signal` (also `/`) speaks exactly the §3.2
protocol. `services/hosted/src/protocol.ts` is a port of
`rustynes_netplay::signaling` (our own code), parser leniencies included, and
it is checked against the Rust reference rather than trusted:
`crates/rustynes-netplay/tests/hosted_signaling_vectors.rs` records what the
reference `Relay` sends for 12 scenarios (72 steps: 2- and 4-player meshes,
a member claiming another seat's `from`, the legacy wire form, rejections, the lobby directory, matchmaking, re-joins,
and the parser's escape and overflow quirks) into
`services/hosted/test/vectors/signaling-vectors.json`, and fails if the Rust
code stops producing it; `services/hosted/test/conformance.test.ts` replays
the file three ways (the TypeScript relay, real WebSockets into the Durable
Object, and the same with the object evicted before every sending step) and
compares every outbound frame as an exact string. The one legitimate
difference is iteration order where the reference has none (a `room-list`
with several open rooms; a `quick-match` with several candidates): Rust's
`HashMap` order is random per process, the Worker's is insertion order.

All rooms live in one Durable Object, not one object per room: a client names
its room only after connecting, and `list-rooms` / `quick-match` span every
room, so per-room routing would need a different protocol. The object uses the
WebSocket hibernation API, persisting each socket's room membership in its
attachment so the relay is rebuilt when the object wakes. Measured caveat: in
the local workerd test runtime a client CLOSE does not wake a hibernated object
(the close never arrived within 4 s, while a message after the same eviction
did), so the eviction replay evicts only before sending steps; Cloudflare
documents the close as delivered after hibernation in production.

**Boundary rules** the reference server lacks, for a public service: frames
over 16 KiB refused, room codes `[A-Za-z0-9_-]{1,32}`, ROM hashes empty or up
to 64 hex digits, a per-socket message bucket, per-IP socket cap (8),
connection-attempt rate (30/min) and room-creation rate (10/min), a global
socket cap (2000), and browsers from origins other than the Pages site and
localhost refused. Refusals use the reference's `error`-then-close shape.

**TURN.** `POST <hosted base>/turn-credentials` (empty body; a GET is
refused, because a browser sends a GET without `Origin` from any page's
`<img>`, which would let other sites spend the cap) mints Cloudflare Realtime TURN
credentials from the TURN key held in Worker secrets, valid for 3 h (why 3 h:
`services/hosted/src/turn.ts`), behind spend guards (per-IP limit, a daily
global cap, a kill switch; `services/hosted/README.md` "Spend guards"). Port-53
URLs are dropped (browsers block them). A refusal is JSON with a reason code,
and the clients then connect with STUN only.

**Clients.** `rustynes_netplay::hosted` is the shared client half: base-URL
resolution (`[netplay] hosted_base_url`, else the build-time
`RUSTYNES_HOSTED_BASE`, else none, which is the default), the derived
endpoints, a bounded parser for the `/turn-credentials` answer, the expiry
check, and the native blocking fetch.

- **Browser:** with a hosted base, the lobby's signaling field starts as the
  hosted lobby, and `BrowserNetplay::connect` fetches TURN credentials (5 s
  timeout) before sending `join`, so no peer connection is built without the
  relay servers; a failure or refusal continues with STUN only and says why in
  the HUD. The timeout's timer is cleared once the fetch settles, and a fetch
  that completes after `leave` or a later `connect` changes nothing and sends
  nothing (each `connect` and `leave` moves a generation number the fetch
  checks).
- **Android / iOS (the §2.5 room-code path):** `NpNetConfig::to_nat_config`
  fills an empty signaling URL with the hosted lobby and, when no TURN trio was
  configured, fetches credentials and hands the first TURN-over-UDP endpoint to
  `NatConnect`'s relay fallback. User-supplied endpoints always win.
  Whether a user-supplied signaling URL enables the room-code buttons is the
  bridge's `np_validate_relay_url` (v3.5.0, `T-MOBILE-RELAY-VALIDATE`), which
  is `signaling_client::validate_signaling_url`, the parser the connect path
  dials with; neither app parses the URL itself. It refuses what the `http`
  URI grammar refuses (no `ws`/`wss` scheme, including an upper-case one; no
  host; whitespace; an unclosed IPv6 literal) and also a port that is not
  digits in 1..=65535 or junk after an IPv6 literal's `]`, which the grammar
  passes through as "no port": before v3.5.0 the connect then dialled 80 or
  443 for `wss://h:notaport`, `wss://h:99999` or `wss://h:`. The verdicts are
  pinned by `crates/rustynes-netplay/testdata/relay_urls.tsv`, run against
  both the parser and the export. Host names are not judged (an invalid DNS
  name fails at resolution).
- **Desktop:** native desktop netplay is direct UDP host/join (§2.4) with no
  signalling or ICE, so there is nothing to wire; `hosted_base_url` is ignored
  there.

**Traffic.** NES rollback traffic is about 22 MB per player-hour (an inference
in the plan); the free 1,000 GB a month (shared with Realtime SFU) is then
about 45,000 relayed player-hours, and only sessions that cannot connect
directly use TURN. No usage forecast exists: the first month's measured egress
is to be recorded here against that figure (runbook step 7).

---

## 4. Spectator mode — read-only (v1.7.0 "Forge" Workstream H8)

A **spectator** joins a running match purely to watch. It is a
determinism-safe, *receive-only* extension of the rollback stack:
`rustynes_netplay::SpectatorSession` (`crates/rustynes-netplay/src/spectator.rs`),
surfaced natively by the Netplay panel's **Spectate** control
(`netplay_ui::NetplayUi::start_spectate`).

**How it stays determinism-safe (the hard contract):**

- A spectator **predicts nothing and rolls back never.** It advances the local
  emulator a frame *only* once every player's real input for that frame has
  arrived (the frame is fully confirmed). Because the players, at a confirmed
  frame, ran it from real inputs only, the spectator — replaying those same
  confirmed inputs from the same deterministic cold-boot — reproduces the frame
  **byte-for-byte** (the same `set_buttons` / Four Score / `run_frame` routing
  as the players, in `apply_and_run`). This is a strict subset of the player
  rollback algorithm.
- It uses the transport **poll-only**: it `send`s no `Input`, `InputAck`,
  `Checksum`, or `Quality`, so it cannot perturb the match it watches. It does
  one optional self-announce `Sync` so a spectator-aware host can learn where to
  relay the stream; after that it is silent.
- It draws no randomness and reads no wall clock. Its effect on the existing
  2-4 player rollback path is **zero** — a spectator is invisible to the players
  (the players' `RollbackSession` already drops any unexpected packet).

A spectator necessarily runs `input_delay + network-latency` frames *behind*
the live match (it can only show a frame it has *received* every input for).
`SpectatorSession::pending_frames` reports how far behind it is, so the frontend
can fast-forward to catch up; the Netplay panel + status bar show `spectate fN
+pending`.

**Delayed-stream buffer (v2.2.0 "Capstone").** On top of that natural lag,
`SpectatorConfig.delay_frames` adds a *configurable, intentional* hold: the
spectator reveals frame `f` only once frame `f + delay_frames` is also confirmed
(`reveal_horizon()` = the confirmed horizon pulled back by the delay). Two uses:
a **broadcast / anti-spoiler delay** (a tournament feed running several seconds
behind so a caster cannot leak an imminent input), and **jitter smoothing**
(holding a backlog of confirmed frames so presentation stays steady over a bursty
relay instead of stall-then-fast-forward). It is purely a *presentation* delay —
the emulated frames are still produced byte-identically and in order; only the
moment each is revealed moves later — so it never sends anything and cannot
perturb the match. The value is clamped to `SpectatorConfig::MAX_DELAY_FRAMES`
(512 ≈ 8.5 s at 60 Hz), which keeps the buffered-but-unshown backlog inside the
bounded lookahead window so a misconfigured huge delay cannot wedge the spectator
behind the horizon. The frontend exposes it as `NetplayUi::spectator_delay_frames`
(default 0 = reveal as soon as confirmed).

**Maintainer-manual carryover (like the live 2-4p matrix):** the host-side
spectator *broadcast/relay* (a host fanning the confirmed input stream to N
spectators) + the `deploy/` relay config are deployment-ready but not
self-certifiable headlessly. The frontend driver + the determinism-safety
property are exercised by unit tests (see the verified table below). Until the
host-side spectator broadcast lands, the panel's Spectate control dials a host
and waits for a stream; the byte-identical replay path it runs on receipt is the
tested-and-proven part.

---

## 4b. Lobby, matchmaking, liveness + desync hardening (v2.2.0 "Capstone")

The v2.2.0 "Capstone" B5 workstream layers browse-and-join matchmaking, a graded
peer-liveness signal, and a hardened desync surface on top of the existing
room-code / TURN stack — all in the signaling / session / transport layers plus
the frontend lobby UI. The deterministic core is untouched and the rollback
determinism contract (byte-identical re-simulation) is preserved.

### 4b.1 Lobby directory + matchmaking (signaling)

The pure signaling protocol (`crates/rustynes-netplay/src/signaling.rs`,
`SignalMessage` + `Relay`) gains a lobby directory and a matchmaking path so a
player can join by *browsing* rather than only by typing a room code:

- **`ListRooms { rom_hash }` → `RoomList { rooms: Vec<RoomInfo> }`** — the client
  asks for the open (joinable, not-full), optionally game-filtered rooms; the
  server replies with each room's `RoomInfo` (code, current player count,
  capacity, `rom_hash`). `RoomInfo` carries **no** SDP / ICE / per-client
  identity — the lobby is a directory, not a transport. The reply is capped at
  `MAX_ROOM_LIST` (256), and the `room-list` JSON array is parsed by a
  brace-depth walk that is likewise bounded, so an oversized frame cannot force
  an unbounded allocation.
- **`QuickMatch { rom_hash, max_players }` → `Matched { room, slot, max_players }`**
  — "quick play": the relay joins the client to *any* open room playing that ROM
  (via the shared `add_to_room` primitive that `Join` also uses — same slot
  assignment + `PeerJoined` nudges), or **creates** a fresh room with a
  deterministic generated code (`QM-NNNNNN`) if none exists. The client learns
  the resolved room code via `Matched` (distinct from `Joined` only in also
  reporting the code, so a matchmade client can display / share it); WebRTC
  pairing then proceeds identically (lower slot offers to higher).

Both new client→server messages are ignored when they arrive from the wrong
direction, and the JSON encode/parse stays hand-rolled + dependency-free like the
rest of the protocol.

### 4b.2 Peer-liveness RTT timeouts (connection)

Once a 2-player `NetplayConnection` is `Synced`, a peer can stall (packet loss, a
paused laptop, an LTE handoff) without ever formally disconnecting. The
connection now grades that as a `PeerLink` signal driven by the time since the
last received datagram (`last_recv`):

| `PeerLink` | Silence ≥ | Meaning |
|---|---|---|
| `Live` | — | a datagram arrived within `peer_interrupt_timeout` |
| `Interrupted` | `peer_interrupt_timeout` (default **2 s**) | warn the user; still recoverable (a late packet restores `Live`) |
| `TimedOut` | `peer_disconnect_timeout` (default **5 s**) | terminal — the connection tears down with `DisconnectReason::PeerTimeout` |

**Why seconds, not Mesen's ~150 ms.** Mesen's netplay declares a peer stalled
after ~150 ms of silence, which is famously trigger-happy: `Quality` pings here
are sent only once per second, and a single dropped ping or a routine Wi-Fi / LTE
retransmit spike routinely exceeds 150 ms of inter-arrival gap on a real internet
path whose RTT is already 60–120 ms — producing spurious "interrupted / desynced"
flapping on otherwise-healthy links. The interrupt threshold (2 s) is two full
ping intervals plus slack, so a single lost ping never trips it; the disconnect
threshold (5 s) matches the multi-second grace windows GGPO / Parsec use. Both
are builder-configurable (`with_peer_timeouts`) for tighter LAN bounds or looser
high-latency relayed play. (The connect-time `handshake_timeout` stays 10 s and
the frame-advantage stall stays `max_rollback_frames` = 8 — these govern
*connecting* and *speculation*, orthogonal to run-time liveness.)

### 4b.3 Hardened desync status (diagnostics)

The observational `DesyncDiagnostics` (§4's Debugging aid) gains a single graded
verdict, `DesyncStatus`, so the panel does not re-derive one from the raw
counters:

- **`InSync`** — every comparison matched.
- **`Suspect { consecutive, first_desync_frame }`** — a mismatch has occurred but
  the current consecutive run is below the confirm threshold. A burst-reordered
  pair of `Checksum` messages can momentarily disagree before the deferred
  `compare_pending_checksums` pass reconciles them, so a single mismatch is *not*
  flashed as a hard desync.
- **`Desynced { first_desync_frame }`** — the consecutive-mismatch run has reached
  `desync_threshold` (default **3**, ≈ 1.5 s at the 30-frame checksum interval).
  This is **sticky**: a rollback desync is unrecoverable (the peers can never
  re-converge without a full state resync), so the surface never downgrades a
  confirmed `Desynced` back to `Suspect` even if a later stray checksum matches.

This remains pure telemetry — it only reads the confirmed-frame digests the
session already exchanges (`NetMessage::Checksum`) and never feeds back into the
rollback, so disabling it leaves every frame / checksum / rollback byte-identical.

---

## 5. What is verified vs. pending

**Verified:**

| Item | How |
|---|---|
| STUN request encode + response decode (XOR-MAPPED + MAPPED, v4/v6) | Unit tests |
| Malformed/short/wrong-cookie/wrong-id rejection | Unit tests |
| Hole-punch state machine transitions | Unit tests |
| Native UDP rendezvous orchestration (`NatConnect`: register → discover → exchange → punch → hand-off) end to end (v1.8.7, §2.5) | Loopback integration test (`tests/nat_loopback.rs`) — drives two orchestrators over **real loopback UDP sockets** through an **in-process WS signaling relay** (the production `signaling::Relay`) + a **mock STUN responder**, both reach `Synced`, hand off `NetplayConnection`s, and run an N-frame `RollbackSession` whose confirmed digests agree (the §2.4 proof shape) |
| Deterministic, look-alike-free 6-char room codes + `PublicAddr` slot routing | Unit tests (`nat_connect` room-code determinism/alphabet; `signaling::Relay` routes `PublicAddr` by slot like SDP) |
| `rustynes-netplay` compiles on `wasm32-unknown-unknown` | Build + clippy |
| TURN relay-transport hand-off for symmetric-NAT pairs (v1.8.7 #40, §2.5) | Integration test (`relay_loopback`) — `UdpTransport::from_relay` routes gameplay over `RelayUdpSocket` against a **mock TURN endpoint** (allocate + permission + relayed-address exchange + N-frame `RollbackSession`); `NpStatus.relayed` reflects the hand-off |
| N-peer UDP roster handshake (3-4 players) | Loopback integration test (`tests/mesh_udp.rs`, real sockets) |
| Native 3-4 player desktop netplay (v3.4.0, `T-NETPLAY-MESH-NATIVE`): every confirmed frame equal on every peer and to the reference over 600 frames, with latency, reordering and loss; the desktop host/join state machine reaches it | `tests/mesh_sync.rs` (4 and 3 peers in memory, 4 peers over sealed loopback UDP, a peer that lost its early inputs) and `netplay_ui` unit tests (`three_players_from_the_ui_stay_in_sync`, `four_players_...`, `two_players_...`) |
| Signaling room/relay protocol (`signaling::Relay`) | Unit tests (default build) |
| Signaling server builds with its feature | `--features signaling-server` build |
| `Roster` wire encode/decode + oversized/malformed rejection | Unit tests |
| Wasm WebRTC frontend wiring + lobby compiles (both `wasm-winit` + `wasm-canvas`) | Build |
| Configurable signaling URL + ICE/STUN list plumbed into `BrowserNetplay::connect` | Build + `wasm_lobby` unit tests |
| Deploy bundle (signaling + Caddy TLS + coturn) builds + is turn-key | `deploy/` Docker images; `.env`-driven, no source edits to deploy |
| Live STUN round-trip | `#[ignore]`d manual probe — **confirmed working live** against `stun.l.google.com`; kept ignored because CI may be sandboxed |
| Desync-diagnostics capture (CRC-match history, first-desync frame, consecutive-mismatch counter) | Unit tests (`rustynes-netplay::diagnostics`) — synthetic CRC sequences; observational only (does not affect the rollback algorithm) |
| Spectator replay is byte-identical to a reference run over the same confirmed inputs (H8) | Unit test (`rustynes-netplay::spectator` — framebuffer compare); `SpectatorSession` sends nothing and waits for fully-confirmed frames |
| Spectator frontend driver (enter `Spectating`, poll-only tick, clean leave) | Unit test (`netplay_ui::spectator_enters_phase_and_leaves_cleanly`) |
| Lobby directory + matchmaking (`ListRooms`/`RoomList`/`QuickMatch`/`Matched`, `RoomInfo`) — open-room filtering, join-existing-vs-create, bounded `room-list` array parse (v2.2.0) | Unit tests (`signaling` — roundtrip, `list_rooms_returns_only_open_matching_rooms`, `quick_match_joins_an_open_room_then_creates_one`) |
| Delayed-stream spectator buffer (`delay_frames`, hold-then-reveal in order, clamp to `MAX_DELAY_FRAMES`) (v2.2.0) | Unit tests (`spectator::spectator_delay_buffer_holds_then_reveals` / `spectator_delay_is_clamped`) |
| Graded desync status (`DesyncStatus` hysteresis + sticky-confirmed) (v2.2.0) | Unit tests (`diagnostics::status_applies_hysteresis_then_confirms_and_sticks` / `threshold_zero_is_treated_as_one`) |
| Peer-liveness RTT (`PeerLink` Live/Interrupted/TimedOut, `DisconnectReason::PeerTimeout`, `with_peer_timeouts`) (v2.2.0) | Unit tests (`connection`) — synced peer graded by `last_recv` against the 2 s / 5 s thresholds |
| Hosted lobby is byte-compatible with the reference `Relay` (v3.4.0, §3.5) | `hosted_signaling_vectors` (Rust: the committed vectors equal the reference's output) + `services/hosted/test/conformance.test.ts` (TypeScript relay, Durable Object over WebSockets, and across hibernation) |
| Hosted lobby boundary rules, TURN spend guards, TTL and expiry, refusal parsing (v3.4.0) | `services/hosted/test/{lobby,turn}.test.ts` (workerd, upstream mocked) + `rustynes_netplay::hosted` unit tests + `rustynes-mobile` `hosted_config` tests |
| Netplay wire parsers never panic / OOM on hostile input (`NetMessage::from_bytes`, `SignalMessage::parse`) (v2.2.0) | `netplay_message` cargo-fuzz target (`fuzz/`) — tens of thousands of clean iterations |

**Debugging aid (v1.3.0 Workstream G1).** When a session desyncs, the native
Netplay panel's read-only **Diagnostics** section surfaces a GeraNES-style
`DesyncMonitor`: the room / input topology (which peer drives which controller
port), the in-sync / desynced-at-frame-N status, lifetime checksum-compare +
mismatch counts, the consecutive-mismatch counter, the most recent local-vs-
remote CRC (classified as a timing/cycle divergence when the framebuffer hashes
match, else a state divergence), and a rolling CRC-match history. It reads the
confirmed-frame digests the session already exchanges (`NetMessage::Checksum`)
and never feeds back into the rollback — pure telemetry, determinism intact.
From v2.2.0 the panel reads the single graded `DesyncStatus` verdict (§4b.3)
instead of re-deriving one, so a lone reordered checksum no longer flashes a
false desync banner.

**Pending (deployment-ready, NOT verified — the maintainer's manual run):**

| Item | Needs |
|---|---|
| Real cross-NAT UDP traversal | A STUN server + two real NATs |
| Live 3-4 player desktop play across machines (v3.4.0) | Three or four desktops that can reach the host's UDP port (LAN, or a forwarded port): the mesh is direct-IP only, with no room code or TURN path for more than two players. The mobile apps do not host or join 3-4 player sessions yet (`MOB-09`, a later release). |
| Live mobile room-code play across two cellular devices (§2.5) | The hosted `deploy/` relay (the same signaling + coturn stack) + two real devices behind carrier NAT; live STUN through CGNAT + the punch are exercised against the *real* network, which CI cannot reproduce |
| Live TURN relay play for symmetric-NAT pairs (§2.5) | A **live coturn** server + **two real symmetric-NAT devices**. The relay-transport hand-off itself is wired and mock-TURN-verified (`UdpTransport::from_relay` + `relay_loopback`, see Verified); only the live cross-network run — which CI/offline cannot reproduce — remains. Cone NAT works end-to-end without the relay. |
| Full browser WebRTC netplay (2-4 players) | The deploy bundle **running** on a host/domain + N real browsers — cannot verify headlessly. Walk the checklist in `deploy/README.md` (2-tab → 2-machine → 4-player matrix + ops/DNS/TLS/TURN-bandwidth steps). |
| Hosted lobby and TURN live (v3.4.0, §3.5) | The Worker deployed to the maintainer's account, then: a two-browser session through the hosted lobby, a relayed session across two NATs, credentials observed to expire, and the first month's measured traffic (`services/hosted/README.md`, "Verifying the live gates") |
| Host-side spectator broadcast/relay + live spectate (H8) | A spectator-aware host fanning the confirmed input stream to N spectators + the `deploy/` relay config running — the frontend driver + byte-identical replay are unit-tested, the live relay is the maintainer's manual run. |
