diff --git a/PROJECT.md b/PROJECT.md
index 72b3179..7c8c18c 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -20,8 +20,8 @@ anything, so if you add a `return` for an awkward deal, check the body still run
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
-Last updated 2026-08-11 (the coaching drill lands as a back-room prototype; the horde's breather restored, the boss walk-on overlay; beats.js
-extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
+Last updated 2026-08-14 (the live 1v1 socket lands: VS A FRIEND on the mode screen, the relay in
+server.py, the receiver-is-judge netcode in src/net.js — phone verdict pending). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
@@ -411,6 +411,46 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
+## Session log: MULTIPLAYER — the live 1v1 socket (2026-08-14). PHONE VERDICT PENDING
+
+Oliver: *"alright multiplayer"* — and, offered the recorded ghost-match step one, he picked the
+LIVE SOCKET instead, plus *"a new button with generated art in the main menu — the possibility to
+start or join a game"*. Built to the architecture LATER item 7 recorded; verified end to end in two
+browser panes through the real relay (pair → serve → mirrored flight → verdict → both scoreboards
+agree on the same point from opposite perspectives). 73 tests.
+
+- **It is CLASSIC TENNIS between two phones** — `G.classic` with the O opponent driven by the other
+ phone instead of the AI. The mode poster is **VS A FRIEND** (`mode_vs.png`, generated in the
+ zombie poster's night-court style); its second step is two chips, HOST — GET A ROOM CODE / JOIN
+ WITH A CODE, and either chip IS the start — a room needs a socket, not a setup screen. `#net`
+ hosts and `#net-ABCD` joins from a URL. The room code lands in a modal over the frozen court
+ (an empty court waiting for a stranger is the calmest court there is).
+- **The relay is ~110 lines of stdlib in server.py** (RFC6455 handshake + framing, `/ws`). It
+ holds NO game state: mints a 4-letter code (no I/L/O/U/0/1 — codes are read aloud), pairs exactly
+ two sockets, forwards text frames verbatim. The host serves first on both scoreboards. 75 s
+ socket timeout against vanished radios; the client heartbeats every 20 s.
+- **The sync unit is THE SHOT** (`src/net.js`): each phone simulates its own court with itself as
+ P; a strike sends ~9 numbers and `mirror()` (x → -x, z → COURT.L - z) rotates them half a turn.
+ The 10 Hz `pos` stream drives the O sprite and carries no authority.
+- **THE ONE RULE: the phone the ball is flying toward is the only judge of that flight** — line
+ call, service box, double bounce, its own swing. The striker's outbound ball is picture
+ (gameplay's tennis bounce block returns early on `G.net && lastHit === 'P'`) and the verdict
+ arrives as a message: `shot` (returned), `fault` (re-serve), `pt` (point, winner relative to
+ sender). This is what stops two sims a latency apart double-scoring a rally — the striker's copy
+ would call a double bounce before the defender's swing had happened. An `applying` latch stops a
+ received verdict echoing back out. Six tests pin the rule from both seats.
+- **startNetVs/endNetVs live in beats.js** under the ownership contract, and `G.net` joined every
+ other start*'s refusal list. Serving is barred while unpaired or dead (`keyPressed`), O's auto
+ serve and rally AI are gated off, `restartRun` closes the socket.
+- **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a
+ CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real
+ work"; a prototype must not start there). prompt() is the join UI. The scoreboard still says
+ CPU. A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the
+ flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification)
+ is the recorded next step once the feel survives a two-phone playtest.
+- **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to
+ provision. Coolify's proxy passes WebSocket upgrades as-is.
+
## Session log: the far court, without spending the lens (2026-08-13)
Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its
@@ -2880,7 +2920,10 @@ never an interstitial. An interstitial after death would land exactly in the "on
is not), and canvas perf at `RES` 4 — the third place that risk lands, after OPEN 1 and item 2
above. `matchMedia('(pointer: coarse)')` does still work there, so the mobile branches are fine.
-7. **MULTIPLAYER: 1v1 zombie tennis (decided worth pursuing, 2026-08-12).** Tennis is one of the
+7. **MULTIPLAYER: 1v1 zombie tennis (decided worth pursuing, 2026-08-12). THE LIVE-SOCKET
+ PROTOTYPE IS BUILT (2026-08-14, staging — see the session log) — Oliver chose the socket over
+ the ghost-match step one. Still open from this item: the ghost match as a solo mode, the
+ wound-decay hook below, reconnect-and-resume.** Tennis is one of the
friendliest genres in existence for netcode, and this codebase is unusually ready for it — record
the decision and the architecture so neither is re-derived:
- **The sync unit is THE SHOT, not world state.** One ball, one legal hitter at a time
diff --git a/assets/mode_vs.png b/assets/mode_vs.png
new file mode 100644
index 0000000..75f30b9
Binary files /dev/null and b/assets/mode_vs.png differ
diff --git a/index.html b/index.html
index 82de44c..453f9c0 100644
--- a/index.html
+++ b/index.html
@@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
-
+