diff --git a/README.md b/README.md
index 03f6550ad..4e19bffa7 100644
--- a/README.md
+++ b/README.md
@@ -178,8 +178,12 @@ desktop put `dsp1b.rom`, `dsp2.rom`, `dsp3.rom` or `dsp4.rom` in `~/.neser/firmw
asks for each chip's file once. See [README-SNES.md](README-SNES.md).
**Play together** (top bar) connects two browsers for a NES or Super Nintendo network game:
-the host copies a code to the other player, who pastes it and sends one back. There is no server,
-so both computers must be on the same network (or VPN). The two must run the same NESER build, and
+the host gets a short room code (like `K7Q-M4X`) to read out or copy, and the other player types it.
+The two browsers meet through NESER's meeting service, liaison (`wss://liaison.rmstdope.eu/ws`), which
+only passes the connection setup between them and never sees the game; they then connect directly,
+so both computers must be on the same network (or VPN). liaison accepts pages served from
+https://neser.rmstdope.eu and from `localhost`/`127.0.0.1` only, so a self-hosted copy reached by another
+address (a LAN IP, say) cannot start a network game. The two must run the same NESER build, and
a connection slower than 120 ms round trip is refused. Once connected the host presses **Start game**:
the game (with any SNES firmware it needs) and the host's current moment are sent to the other
player, who needs no copy of the game and does not keep it, and both start from that moment after a
diff --git a/architecture.md b/architecture.md
index de423f6aa..8d438abbd 100644
--- a/architecture.md
+++ b/architecture.md
@@ -567,7 +567,7 @@ The web frontend is bundled with **Vite** (config at `vite.config.ts`, root: `we
| `web/src/save-state/` | Save state persistence using IndexedDB (`save_state_storage.ts`, `save_state_controller.ts`, `save_state_context.ts`), offered where the console table says so: NES, Game Boy and SNES games (`save_state_support.ts`), and the Load button's preview of the saved state, a picture of its frame and when it was saved, kept in the IndexedDB database `neser-save-previews` (`save_preview.ts`). |
| `web/src/debugger/` | Browser-based debugger panels — disassembly, OAM viewer, watch expressions, PPU viewer layout/scroll — docked beside the picture, and whether the top bar shows its button (`debugger_dock.ts`, kept in `localStorage` under `neser.debuggerButton` and set from the settings panel's Developer tab; F5 opens it either way). |
| `web/src/shortcuts/` | Keyboard shortcut actions, and which key targets they leave alone (`keyboard_target.ts`: editable fields and open dialogs). |
-| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `code.ts` (the host and reply codes: `NSR1-` and the base64url of a deflated `{kind, version, commit, sdp}`, read back as ok, not a NESER code, the wrong kind, or another build), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer with no STUN/TURN, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable links: the host's 15 s connect limit and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. |
+| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `room_code.ts` (the six-character room codes from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, shown as `ABC-DEF`, and tidying what is typed into one), `liaison.ts` (nr-z4nw: the only `WebSocket` user, a client for the liaison meeting service at `wss://liaison.rmstdope.eu/ws`, github.com/rmstdope/liaison: `create`, `join`, `signal`, and its answers read back as untrusted; liaison's TURN relay is not used), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer with no STUN/TURN, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable signaling and links: the host opens a liaison room and shows its code, the joiner joins by it, and the offer and answer cross as `signal {offer}` / `signal {answer}`, after which each side leaves liaison; a room that ran out, or whose joiner left before the swap, is replaced by itself; the joiner keeps one liaison connection across its tries so liaison's wrong-code limit holds; a 15 s connect limit from the joiner being in the room and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. |
| `web/src/ui/` | Toast overlays (`toast_overlay.ts`), gamepad init toast, the control bar's volume (`volume.ts`, key `neser.volume`) and game title (`game_title.ts`) helpers, the settings panel (`settings_panel.ts`), the short lists Filter, Palette, Colours and Reset open (`choice_menu.ts`), the Play together window and its top-bar status chip (`play_together.ts`, drawing `netplay/session.ts`'s view one step at a time, Start game and the send's progress included), the 3-2-1 counts before and while playing together (`countdown.ts`), the note once both games run slower because one computer can't keep full speed (`slow_note.ts`, nr-ayk5.7: five seconds, once per play together, gone as soon as the game stops), the paused, waiting, catching-up, resyncing and ended screens over the shared game and the "End playing together?" question (`play_screen.ts`), the Keys and controls panel H and the top bar's ? open (`help_panel.ts`: the shortcut rows and a controller drawn for the running console's keys), where an error shows and its library title (`errors.ts`: a failed pick in the library, a stopped game over the picture), and the library (`library.ts`: grouping, search, system filter, and the release-news wave). |
| `web/integration/` | Playwright-based end-to-end integration tests for the web frontend. |
diff --git a/web/index.html b/web/index.html
index ce520b92a..89ee16b1f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -190,7 +190,7 @@
Bundled test ROMs
-
+