Generated: 2026-09-28 (rev 2 — post-review: net module re-homed under
core_bridge/, CI strategy reworked, input/pause/rematch/teardown contracts
made explicit, N7 re-scoped after verifying the core is integer-only)
Adds online 1v1 versus to Blockfall on top of the existing local versus stack (T24–T26). Design decisions, confirmed with the author:
- Connection: direct IP/port. Host opens a listening port; guest types
ip:port. No lobby, no signaling server, no LAN discovery (post-v1). - Sync model: deterministic lockstep with input delay. Both peers run
the identical
tetris_core::versus::Matchfrom one shared seed; inputs land D ticks late (delay-based, default 8 fixed steps ≈ 133 ms). No rollback, no state streaming — the opponent's board, HUD, next/hold, and pending garbage all come for free from the local deterministic mirror. Host is the authoritative tick clock; periodic snapshot-hash comparison detects desync. - Transport:
bevy_renet5.0.0 (crates.io verified 2026-09-28: depends onbevy_app ^0.19, compatible with the pinned Bevy 0.19.1) with its default netcode transport (encrypted UDP, connection management, ReliableOrdered / ReliableUnordered / Unreliable channels). MIT/Apache — compatible with the GPL-3.0 app.
Integration philosophy mirrors T25: the net layer lives under the existing
bridge as crates/tetris-app/src/core_bridge/net/ (declared by
core_bridge/mod.rs, which already declares mod versus;), and NetPlugin
is mounted from CoreBridgePlugin::build() — the exact precedent
VersusBridgePlugin uses. main.rs stays frozen (it is the only crate
root: mod declarations at main.rs:9-18, the only production plugin list at
main.rs:50-60). The net systems gate the existing versus systems in and
out; the tetris-core public API stays frozen; solo play is untouched; the
AppState enum is unchanged (net UI gates on NetSession status, like T26's
VersusFlow stage machine).
Determinism premise, verified at plan time: tetris-core contains zero
f32/f64 (timers are u32/u64 tick counters, e.g.
game.rs:77-78), and Action, MatchSnapshot, GameSnapshot, Board all
derive Serialize + Deserialize (actions.rs:11, versus.rs:158,
game.rs:37, board.rs:22). Lockstep mirrors are therefore cross-platform
bit-exact by construction; no FP/FMA caveat is needed.
Existing hooks this plan stands on:
tetris_core::versus::Match—new(seed, rule),apply(side, action),tick(side),snapshot(); serde types; fully deterministic from seed + actions (proven by T24 tests + nightly soak).core_bridge/versus.rs—VersusMatch(NonSend),Controller {Human,Bot},VersusWinner,VersusEvent,start_versus/end_versus, the 60 Hzversus_bridge_systemdrain-then-tick order (actions first, thenMatch::tickleft,right —core_bridge/versus.rs:256-264),versus_bot_system,versus_restart_on_r_system(R onUpdate), theTETRIS_1V1in-process env-harness pattern.input.rs—VersusActions {left, right},VersusBindingsP1/P2 presets, the lone-human preset override (input.rs:742-764—lone_human_p1currently triggers wheneverp2is notHuman: N4 must account for it, see below), DAS/ARR viaShiftRepeat/RepeatTimer.screens_menu.rs—VersusFlowstage machine, winner overlay (winner_textat :275 exhaustively matchesController), pause chord (pause_chord_system:507-532),VersusRematchButton→rematch_versus→start_versus(which re-seeds locally — dangerous for mirrors, gated in N5), plus two recorded visibility regressions (:1646-1693 click-swallowing active versus HUD; :960-975/:2055-2062 ZIndex click-through) that N3/N5's teardown contract must respect.core_bridge/mod.rs:573—CoreBridgePlugin::build()mountsVersusBridgePlugin; same slot mountsNetPlugin.settings_persist.rs— load/save helpers reused by a separatenet_profilepersistence (the T1 contract instate.rs:1-3forbids reshapingSettings).
Channel usage (client→server = guest→host; server→client = host→guest).
Send types are design intent — N1's spike finalizes exact renet 2.0
channel/message APIs (note: resend_time is a ReliableUnordered knob;
ReliableOrdered resends continuously — do not treat the table as verified
API):
| Msg | Dir | Channel (intent) | Notes |
|---|---|---|---|
Hello { version, delay } |
guest→host | ReliableOrdered | version must equal PROTOCOL_VERSION; delay = guest's desired input delay, both sides adopt D = max(host, guest) |
MatchStart { seed, rule } |
host→guest | ReliableOrdered | guest never starts play without it; also implements rematch (host sends a fresh one) |
TickInput { tick, actions } |
guest→host | ReliableOrdered | arrives ≈D ticks early; D absorbs jitter |
TickBatch { tick, left, right } |
host→guest | ReliableOrdered | sent every tick (empty lists included) — doubles as clock pacing; batch T is emitted when tick T−D executes |
SnapshotHash { side, tick, left, right } |
both | ReliableUnordered | every 60 ticks; mismatch → desync teardown |
Bye |
either | ReliableUnordered | clean exit → peer tears down to title overlay |
- Roles: host = netcode server (
max_clients: 1) and alwaysSide::Left; guest = netcode client and alwaysSide::Right— identical to the local P1/P2 split, so T26's versus HUD/viewport code is reused unchanged. - Tick clock: the host's counter over
FixedUpdatesteps is authoritative; the guest executes batches strictly in tick order. A tick whose remote input hasn't arrived executes with an empty action list (never wait — the hash check would otherwise flag a stall as divergence; late inputs are dropped and logged, which is exactly whyDis negotiated asmax). - Input path: each side's local player queues actions for tick
T + D; they are queued locally AND sent immediately asTickInput { T + D }. At batch build time the host merges its own delayed queue with the guest's already-arrivedTickInputfor that tick, applies both sides to its localMatch, and ships the batch; the guest mirrors it. - Serialization: bincode 1.3 over the serde types. Snapshot hash: FNV-1a over
the bincode bytes of
MatchSnapshot(process-stable, unlike stdHash). - Auth: v1 uses
ServerAuthentication::Unsecure+ fixedPROTOCOL_ID(netcode still rejects wrong-protocol traffic). No session tokens in v1. - Netcode limitation, designed around: with
max_clients: 1a second inbound client is silently dropped by the transport (no server event). "Match full" is therefore indistinguishable from "host offline" and is presented as a join-timeout message, not a distinct event.
N1 ── N2 ── N3 ── N4 ──┬── N5 ──┐
│ ├── N8 (docs)
└── N6 ──┼── N7 (soak/audit)
└────── N8
Wave: 1 2 3 4 5(N5,N6) 6(N7,N8)
- depends_on: []
- location:
Cargo.toml(workspace deps),crates/tetris-app/Cargo.toml,crates/tetris-app/src/core_bridge/net/mod.rs(new),crates/tetris-app/src/core_bridge/net/protocol.rs(new),crates/tetris-app/src/core_bridge/mod.rs(onemod net;line) - description: Add
bevy_renet = "5.0.0"(default netcode feature) andbincode = "1.3"to[workspace.dependencies]+tetris-appdeps. First action — the spike: against the downloaded crate source, pin and record in anet/mod.rsdoc section ("Verified API notes") every bevy_renet 5.0.0 / renet 2.0 / renet_netcode 2.0 surface N2/N3 will code against: message payload encoding (raw bytes vsSerializeintegration),ChannelConfig/SendTypeshapes,RenetServerPlugin/RenetClientPluginresource and event types (RenetServer,RenetClient,RenetServerEvent,RenetReceive/RenetSendsets), netcodeServerConfig/ClientAuthenticationfields, whether the bound port is queryable when binding port 0 (else fixed-port test strategy), transport drop/teardown semantics for un-listening, and connect timeout configurability. Then definePROTOCOL_ID: u64,PROTOCOL_VERSION: &str, theNetMsgenum (Hello{version,delay}, MatchStart, TickInput, TickBatch, SnapshotHash, Bye — embeddingAttackRule,Action, ticku64,IoError-free payloads),encode/decodehelpers,decodetolerant of unknown variants →Err(ProtocolError), andsnapshot_hash(&MatchSnapshot) -> u64(FNV-1a over bincode bytes). - validation:
cargo check --workspace; round-trip unit tests for everyNetMsgvariant incl. malformed/short-bufferdecoderejection; hash stability tests (same state equal, differing state differs); all existing tests green (do not pin an exact count); clippy-D warnings+ fmt clean. - status: Completed
- log: Deps landed: bevy_renet 5.0.0 / renet 2.0.0 / renet_netcode 2.0.0 (+
new transitive
renetcode2.0.0 protocol crate) / bincode 1.3.3 — plan version assumptions held. Full verified-API notes written incore_bridge/net/mod.rswith file:line citations. Key corrections to plan assumptions: (1) second-guest is NOT silently dropped — netcode sends aConnectionDeniedpacket at max_clients (renetcode server.rs:303), and the guest can read it via transportdisconnect_reason(), so "match full" is distinguishable client-side (host side still gets no event); (2)resend_timeexists on both reliable SendType variants, not just ReliableUnordered; (3)ConnectionConfighas no timeout field — timeout rides on the netcode connect token and is hard-coded (expire 300 s / timeout 15 s) forClientAuthentication::Unsecure, so the ~10 s JoinTimeout watchdog must be app-side (correctly ordered); (4)ServerConfigalso requirescurrent_time: Durationand has no Default; (5) server transport has nolocal_addr()— bind theUdpSocketyourself, readlocal_addr()before moving it in, setpublic_addressesfrom it (client transport does exposeaddr()); (6)RenetServerEvent/NetcodeErrorEventare Bevy 0.19 observer triggers (On<RenetServerEvent>viaapp.add_observer), not readable message queues — bridge them intoMessages<NetEvent>from an observer; (7)NetcodeServerPlugin/NetcodeClientPluginare required in addition to the two renet plugins (they ownsend_packets); (8) teardown verified: zeroDropimpls anywhere, transports own their sockets → removing transport resources frees the port synchronously; alsoNetcodeClientTransport::updateerrors every frame after a disconnect — tear down promptly; (9)MinimalPluginsincludesTimePlugin, so the N2 headless-test fallback concern is moot (use manualapp.update()). Codec:NetMsg(6 variants incl.MatchStart.match_delay),PROTOCOL_ID,PROTOCOL_VERSION,encode/decode(bincode fixint encoding ==bincode::serializedefaults, plusreject_trailing_bytesfor strictness),snapshot_hash= FNV-1a-64 over bincode bytes. TDD: 7 unit tests written first — RED (5 failed: todo! stubs + one genuine fixture finding:GameSnapshotcarries no timers, so N ticks vs N+1 at level 1 gravity is the same snapshot — hash-equality there is correct; fixture switched to action-driven divergence) → GREEN. All gates green: check/fmt/clippy -D/cargo test --workspace(146 app + 136 core + 6 integration). - files edited/created:
Cargo.toml(workspace deps),Cargo.lock,crates/tetris-app/Cargo.toml,crates/tetris-app/src/core_bridge/mod.rs(mod net;),crates/tetris-app/src/core_bridge/net/mod.rs(new — spike notes),crates/tetris-app/src/core_bridge/net/protocol.rs(new — codec + tests),netplay-plan.md(this entry)
- depends_on: [N1]
- location:
core_bridge/net/mod.rs,core_bridge/net/session.rs(new),core_bridge/mod.rs(mountNetPlugininsideCoreBridgePlugin::build()) - description:
NetPluginaddsRenetServerPlugin+RenetClientPluginand aNetSessionresource:role: NetRole {Host, Guest},status: NetStatus {Idle, Listening, BindFailed(String), Connecting, Handshaking, Ready, InMatch, Lost(NetLossReason)}, plus owned transport resources while active. Free functions:net_host(port)(bind0.0.0.0:port,ServerConfig {max_clients: 1, protocol_id: PROTOCOL_ID, authentication: Unsecure, public_addresses: bound};std::net::UdpSocketbind error →BindFailed(msg), no panic),net_join(addr),net_stop()(drop transport resources → port released; used by Esc-on- Listening and all teardowns). Bridging systems on the renet sets: serverClientConnected→ expectHello; valid →Ready, wrong version → kick, guest mirrors: connected →Hello{version, delay}→ onMatchStart→InMatch.client_just_disconnected/ClientDisconnected/ connect timeout →Lost(reason). OneNetEventBevyMessage(PeerConnected,PeerLost(reason),VersionMismatch,BindFailed(msg),JoinTimeout[≈10 sConnectingwatchdog],Desync {tick},ByeReceived) for N5's UI. Host-side delay adoption: onHello,D = max(local, peer)on both sides (guest learns it fromMatchStart.match_delay; add that field). renetupdate/send_packetsare driven by the bevy_renet plugin — never poll renet manually. - validation:
NetStatustransition-table tests with pure logic factored out; an in-process two-Appintegration test inside the crate (#[cfg(test)]insession.rs): twoApps withMinimalPlugins+RenetServerPlugin/RenetClientPlugin+ netcode transports on a loopback port (fixed test port fromTETRIS_TEST_NET_PORTenv with a deterministic default, documented collision caveat), assert connect → bothReady, drop client → hostLost. If MinimalPlugins proves insufficient for the renet plugin's system-set ordering, fall back to addingTimePluginand note it in the mod doc. clippy/fmt clean; all existing tests green. - status: Completed
- log:
NetPluginmounted next toVersusBridgePlugininCoreBridgePlugin::build(); it registers all four bevy_renet plugins (N1 correction #7 applied),NetSession,Messages<NetEvent>and twoUpdatebridge systems (eachrun_ifits transport resource exists) → zero cost whileIdle. FSM factored into the purenext_status(role, status, trigger)+NetSession::apply. TDD: 16 tests written first — RED (all panic on thetodo!()stub) → table implemented → GREEN. 17 tests total: 10 pure transition-table tests (both roles, every state, Stop-from-every-state, illegal-trigger ignores), a watchdog test, an occupied-portBindFailed-no-panic test, a version-mismatch kick test (via renet'snew_local_clientseam — no UDP), and the two-Apploopback connect/Ready/Bye+ graceful-exit tests. The loopback test is inherently GREEN-after-implementation (transport wiring can't fail before it exists) — documented in the test header. Observer consumption (Bevy 0.19):app.add_observer(fn ev: On<RenetServerEvent>), patterns via**event(On→RenetServerEvent→Deref→renet::ServerEvent); observers can't writeMessages, so they buffer into a privateNetSession::server_notesqueue that the host system drains (same frame).NetcodeErrorEventdeliberately unobserved — unobserved triggers are no-ops, and the post-disconnect error spam is silenced by dropping transport resources the same frame. Client side pollsis_connected/is_disconnected+NetcodeClientTransport::disconnect_reason()instead of the built-inclient_just_*predicates (theirLocal<bool>is one-shot per schedule — edge detection done manually). API surprises found: (1) renet_netcode re-exports the netcode reason under the alias itself — importbevy_renet::netcode::NetcodeDisconnectReason, the base name fails; (2) that enum has 7 variants (N1's notes omitConnectionResponseTimedOut— mapped toTimeout); (3) host-side loss reasons are flattened: renet'sremove_connection(server.rs:129-134) emitsClientDisconnected{reason: Transport}for any netcode-originated loss (clean disconnect packet and 15 s timeout alike), so the host seesLost(Transport)for "peer vanished" — the guest keeps full granularity (Denied/Timeout/…); escape hatch if N5 wants the split host-side:NetcodeServerTransport::time_since_last_received_packet; (4)RenetClientconstructs inConnecting— no false disconnect on join; (5) dropping a peerAppwithoutnet_stop()sends nothing (survivor waits the full 15 s) — all exit paths must callnet_stop()(graceful, ~1 frame; loopback-tested). Guest FSM clarification: guest passes throughReady(Handshaking= Hello queued,Ready= flushed/awaitingMatchStart) so both peers readReadyafter handshake and N5's "waiting-for-host" line exists. Handoff boundary for N3: N2's systems drain the renet channels only during the handshake window (hostHandshaking, guestHandshaking|Ready) — fromInMatchon N3 owns every message incl.Bye(documented at the top ofsession.rs). Test port:TETRIS_TEST_NET_PORT(deterministic default 34857),TEST_NET_LOCKstatic serializes all socket tests in-binary (N6 must share it); foreign-collision caveat documented. Gates: fmt, clippy-D,cargo test --workspace(163 app + 136 core + 6 integration). - files edited/created:
crates/tetris-app/src/core_bridge/net/session.rs(new — session FSM, plugin, free fns, bridge systems, tests),crates/tetris-app/src/core_bridge/net/mod.rs(mod session;+ re-export),crates/tetris-app/src/core_bridge/mod.rs(NetPluginmount),netplay-plan.md(this entry)
- location:
core_bridge/net/mod.rs(submodule decl),core_bridge/net/lockstep.rs(new) - description: The tick driver for
NetStatus::InMatch, onFixedUpdatein place of the versus bridge's direct drain (N4 installs the gate; here, provide systems +NetLockstepresource: per-side pending-input ringVecDeque<(tick, Vec<Action>)>, host'sremote_inputsfed fromTickInput,tick: u64host counter / guest next-expected-tick + a ≤D batch buffer). Delay fromMatchStart.match_delay(env default 8 viaTETRIS_NET_DELAY, clamp 2..=30 —SEED_ENVenv pattern). Ordering must mirrorversus_bridge_system: apply actions, thentick(left),tick(right); emitVersusEventmessages for everyMatchEventproduced, so T26 juice/audio/HUD work unmodified. Host per step: buildTickBatch {tick, left, right}from the due delayed-queue + arrived remote inputs (missing remote ⇒ empty list, count adropped_late_inputsdiagnostic), apply to localMatch, send batch, bump tick. Guest per step: if batch for expected tick buffered → apply (actions then tick both sides), else stall (reliability guarantees eventual arrival; count astall_stepsdiagnostic). Both sides emitTickInput {tick, actions}for their local side's queued actions. Every 60 ticks both sendSnapshotHash(host+guest full-match hash viasnapshot_hash(match_.snapshot())); mismatch →NetEvent::Desyncand the teardown contract below. Gates: skip whenSimPaused.0or*app_state != Playing— this is what makes the Lost/Desync freeze real (N5 flipsSimPausedand shows the overlay; the lockstep then simply stops ticking whileVersusMatch.activestays true so the frozen boards remain rendered under the overlay). Teardown contract (both desync andLost/Bye): (1) setSimPaused, show the net overlay root with an explicit highZIndex(documented regressions atscreens_menu.rs:960and:2055show why); (2) the match staysactive(frozen boards visible behind the overlay, versus HUD must not swallow the overlay's buttons — cover with a click-ability test); (3) the user's "Back to title" click runs anend_versus-style full teardown (active=false, roots restored to the clean-solo regime,NetSession → Idle). - validation: fake-transport seam (
NetOuttrait, N3-owned) running both "peers" deterministically in one process: delay math (input at t batched at t+D), empty-tick correctness, guest stall-and-recover on artificially delayed batch delivery, late-input drop accounting, desync detection on a deliberately forked mirror, teardown contract checks (SimPaused honored, no tick progress while paused); proptest: twoMatches fed the sameTickBatchstream are snapshot-identical at every tick. All existing tests green; clippy/fmt clean. - status: Completed
- log:
NetLockstepresource + pure state machine inlockstep.rs(reset_for_match,schedule_local,ingest,step_host,step_guest), every step method taking&mut Match+ a&mut dyn NetOutso production logic runs verbatim in-process over the fake transport.NetOuttrait seam (send-only) withRenetServerOut/RenetClientOutrenet adapters; receive viaserver_inbox/client_inbox(poll both channels, decode, never panic on hostile bytes) →ingest.wire_channel(): Hello/MatchStart/TickInput/ TickBatch on ReliableOrdered, SnapshotHash/Bye on ReliableUnordered. TwoFixedUpdatesystems (net_lockstep_host_system,net_lockstep_guest_system) mounted byNetLockstepPluginfromNetPlugin::build(); all params butNetLockstepoptional → inert in non-netplay apps (add_message::<VersusEvent>re-registration is idempotent, Bevy 0.19contains_resourceguard). Stepping order (apply_batch, the single path host and mirror share — proptest-pinned): apply left actions →tick(Left)→ apply right →tick(Right), mirroringversus_bridge_system(versus.rs:255-264); systems writeVersusEventperMatchEvent, bumpVersusMatch::steps, and crownVersusWinneronce (winner.0.is_none()guard —start_versusclears it). N4 gating contract: whileInMatch,versus_bridge_systemmust skip its entire drain-then-tick block incl. its own steps/crowning (double-stepping corrupts the mirror) and pin.after(versus_bot_system)(private toversus.rs) so Bot-seat pushes schedule with delay like human actions. Delay: local actions schedule fortick + DAND emitTickInputimmediately; both peers emit; the guest discards the host'sTickInputecho and the host discards strayTickBatchvia arolefield the systems mirror fromNetSession— a lateTickInputwhose target already executed is dropped and counted indropped_late_inputs; the guest mirror applies only batch contents (guest's own ring is bookkeeping), so late input can never fork the boards. Stall policy: host sends a batch every tick (empty lists included — clock pacing); guest executes strict in-order from a sortedbatch_buffer, countingstall_steps. Desync check: everyHASH_CHECK_PERIOD(60) executed ticks (labels 59, 119, …) both sides sendSnapshotHash { side, tick, left: h, right: h }— both fields carry the full-matchsnapshot_hash(N1 codec exposes only the whole-MatchSnapshothash; per-side granularity deferred — worth a plan amendment if N6 wants per-side localization); rolling 8-entry windows per direction, mismatch on a common label →LockstepSignal::Desync{tick}+ windows cleared (no repeat-fire). Gates (freeze mechanism): skip whileSimPaused.0/AppState != Playing/ no transport (host also needssession.peer) /!VersusMatch::active; paused skip holds theVersusActionsqueues, inactive-match skip clears them. Teardown contract shipped: Desync/Bye signals setSimPausedand writeNetEvent::Desync/ByeReceived(Bye also drivesNetTrigger::Bye→Lost(PeerDisconnected);NetSession::applyopened topub(crate)); the match staysactiveso frozen boards keep rendering;net_freeze+net_leave_to_title(gracefulnet_stop+ reset + un-pause +end_versus) andNET_OVERLAY_ZINDEX(ZIndex(100), strictly above HUD 0 / submenu 1 per the recorded click-swallow regressions) are N5's hooks. A mid-matchMatchStartparks inpending_startfor N4's rematch.net_stopfixed to removeRenetServer/NetcodeServerTransportindependently (previously a transport-less server survived → notIdle). Validation — 26 tests (app suite 163 → 189): 13 pure fake-transport (NetSim: latency-controlled in-memory links, real encodedNetMsgs) covering delay math, empty ticks, stall-and-recover, late-input accounting, batch merge, 60-tick hash exchange, forked-mirror desync detection on both peers,Byesignal,pending_startparking, stale-batch ignore; a 256-case proptest pinning the mirror invariant (same batch stream ⇒ snapshot- and hash-identical) and cross-checkingsnapshot_hash⇔snapshotequality; 9 Bevy-system tests over renet'snew_local_clientseam (no UDP) covering inert-while-not-InMatch, host pacing +TickInput(t+D)+ no early application,SimPausedhold/ resume, non-Playingfreeze, inactive-match guard, desync freeze +net_leave_to_titlefull teardown, wireBye, winner crowning through the lockstep, guest drain/stall/recovery; plus a thin real-UDP loopback smoke (port 0 — no fixed-port lock needed) round-tripping a guest dropTickInput→ host batch → guest mirror with hash-equal boards. RED→GREEN note: tests and impl were authored in one pass against the planned API; the first wiredcargo check --all-targetsand test run failed (transport accumulator bug in the fake seam, guest-role echo handling, dial-vs-bind address mismatch in the loopback test), all fixed to GREEN — the fake seam demonstrably catches real wiring faults. API surprises: (1)SnapshotHashcarriesside(N1 notes underplay it) but the hash is whole-match; (2) guest must NOT reset after the session flipsInMatch—FixedUpdateruns after theUpdatebridge in the same frame, soMatchStart+ first batches can already have executed (loopback test resets before the host starts the clock — N4: reset the guest's lockstep when handlingMatchStart, i.e. before the next fixed step); (3)AppState::Playingis the default state, so netplay gating is live from the first frame; (4) 1.95 clippy:is_multiple_ofover%,too_many_argumentsallow on the step systems (repo convention).TETRIS_NET_DELAYdesire flows through N2's negotiation;reset_for_matchre-clamps defensively. Gates: fmt, clippy-D,cargo test --workspace(189 app + 136 core + 6 integration). - files edited/created:
crates/tetris-app/src/core_bridge/net/lockstep.rs(new — state machine,NetOutseam, systems, teardown contract, tests),crates/tetris-app/src/core_bridge/net/mod.rs(mod lockstep;),crates/tetris-app/src/core_bridge/net/session.rs(apply→pub(crate),NetLockstepPluginmount,net_stopindependent-removal fix),crates/tetris-app/Cargo.toml(+Cargo.lock) (proptest dev-dep),netplay-plan.md(this entry)
- depends_on: [N3]
- location:
core_bridge/versus.rs,input.rs,screens_menu.rs(one-line carve-out only, see item 3) - description:
- Extend
ControllerwithNet(additive variant). - Cross-file compile carve-out:
winner_text(screens_menu.rs:275) exhaustively matchesController, so N4 adds a minimalNetarm ("OPPONENT" label) inscreens_menu.rs— N5 refines it. (Documented exception to N5's file ownership; waves are sequential anyway.) - Gate
versus_bridge_system: whenNetSession.status == InMatch, skip the directMatch::apply/tickdrain — N3's lockstep owns stepping — while keepingVersusWinnersurfacing andstepsbookkeeping alive. Suppress R-restart inversus_restart_on_r_system(that's the actualUpdate-set R handler, not the bridge drain) duringInMatch— a local reseed would destroy mirror sync. versus_bot_system: unchanged. ABotside pushing its own side's local queue transparently feeds the net input path — zero special-casing, and the mechanism N6's bot-vs-bot relies on.- Input routing in
versus_input_system: whileInMatch, only the local side's queue is fed from keys (host: left/P1 preset; guest: right/P2 preset); the remote side's queue is never touched by local keys. Binding-preset fix: the lone-human override (input.rs:750,lone_human_p1 = p1_human && !p2_human) currently fires for the host seat pairHuman+Netand would hand the host the arrow/solo-alternate preset; treat aNetseat as occupied (!(p2_human || p2_net)), so host getsbindings.p1(WASD) and guest getsbindings.p2(arrows) via the normal two-seat path. Preserves all existing local-versus behavior (Net never appears locally). Not fully "additive" — a deliberate behavior adjustment scoped toNetseats; pinned by tests. start_net_match(rule, local_side, seed, delay)/end_net_matchfree functions mirroring thestart_versus/end_versuspattern: host honorsSEED_ENVfor harness runs else wall clock, constructs theMatchlocally AND sendsMatchStart; the guest constructs itsMatchonly from the receivedMatchStartseed/rule/delay — never a locally derived seed.
- Extend
- validation: all existing tests green unmodified; new gate-matrix tests
(net Idle → bridge path byte-identical to today; InMatch → drain skipped,
winner surfacing alive, R suppressed); preset tests: host seat pair
Human+Netdrives left via P1 bindings, guest seat pairNet+Humanvia P2 bindings, remote queue untouched by local keys; lone-human local-versus behavior regression test (vsBotstill gets the arrow+solo-alternate preset);start_net_matchseed-propagation test (guestMatchseed == host's). clippy/fmt clean. - status: Completed
- log:
Controller::Netadded (additive). No existing test was modified — the only cross-file compile carve-out waswinner_text(screens_menu.rs:277), which gets a minimalController::Net => "OPPONENT WINS"arm (+comment line); N5 refines this arm's copy (it is documented as N5-owned copy, this is the N4→N5 handoff). Bridge gating:versus_bridge_systemgainsOption<Res<NetSession>>and early-returns onstatus == InMatchbefore the!activequeue clear — the lockstep systems own the queues,stepsand crowning inInMatch(no double-step). With the sessionIdle/absent the path is byte-identical (net_idle_leaves_the_bridge_drain_path_byte_identicalasserts the stepped snapshot equals a hand-advancedMatch).versus_restart_on_r_systemgains the same optional param and is inert inInMatch(local reseed risk).versus_bot_systemunchanged — aBotlocal seat's pushes schedule with delay like human actions. Ordering pinned inVersusBridgePlugin:versus_bot_system.before(net_lockstep_host_system).before(net_lockstep_ guest_system)— the lockstep fns appear only in.before()(records an edge, never re-adds them; no-op when a plugin is mounted without the other), and the bridge keeps its existing.after(versus_bot_system). Input (input.rs): lone-human override nowp1_human && !(p2_human || p2_net), so aNetseat counts as occupied. This is the deliberate,Net-scoped behavior adjustment (not pure additive) the plan called for; the RED→GREEN evidence isnet_host_seat_pair_uses_p1_wasd_and_never_the_remote_ queue(fails before the fix: the host took the arrow preset). The two-seat configuration N5 arms (Human+Nethost /Net+Humanguest) is the routing: aNetseat getsout=Noneindrive_versus_side, so local keys can never reach the remote queue — no role branch needed inversus_input_system.Human+Botlone-human behavior untouched (regression test added:lone_human_vs_bot_still_gets_arrow_and_solo_alternate_preset). Lifecycle (versus.rs,&mut Worldfree fns):start_net_match(world, rule, local_side, seed, delay)— host honorsSEED_ENV(env_seed(). unwrap_or(seed)) for harness runs else the caller'sseed(N5 = wall clock, N6 = fixed), flipsReady→InMatchviaenter_match(),reset_for_match, builds its mirror, and sendsMatchStart{seed,rule,match_delay}viaRenetServerOut. Guest: builds its mirror only fromMatchStart(never a local seed) —end_net_match(world)= lockstep reset +end_versus(leaves transport/session alone; N5's Back-to-title usesnet_leave_to_title). N4 contract hook (session.rs, recorded):guest_net_systemgained aResMut<NetLockstep>param and now parks the initialMatchStart(Handshaking|Ready) intopending_startalongside the status flip, so a single consumer (guest_pending_start_system, Update, guest-only) rebuilds the mirror identically for first start and mid-match rematches. The host never consumespending_start(shields it from a hostile mid-matchMatchStart). Seed-propagation test = real-UDP two-app loopback (start_net_match_ propagates_the_seed_to_the_guest_mirror, port 0 like N3's): host starts viastart_net_match, guest mirrors purely off the wire, assertsguest.seed == host.seed(a wall-clock seed could never equal the fixed one) and hash-equal mirrors after 30 shared lockstep ticks. GREEN-after- implementation (the wiring can't fail before it exists); the gating correctness is non-vacuously proven by that loopback staying hash-equal (an ungated bridge would fork it) and by the RED preset test. Winner surfacing inInMatchis alive via the lockstep (N3'swinner_surfaces_through_lockstep) — N4's gate defers crowning to it (verified: the gated bridge writes neitherVersusWinnernorstepsinInMatch). Gates: fmt, clippy-Dclean;cargo test --workspace= 197 app (+8 new) + 136 core + 6 integration, all existing tests green UNMODIFIED. - files edited/created:
crates/tetris-app/src/core_bridge/versus.rs(Controller::Net, InMatch gating of the bridge + R handler, lockstep ordering pin,start_net_match/end_net_match/setup_net_mirror/guest_pending_start_system, gate-matrix + lifecycle + loopback tests),crates/tetris-app/src/input.rs(lone-humanNet-seat fix + preset tests),crates/tetris-app/src/screens_menu.rs(one carve-out:winner_textController::Netarm),crates/tetris-app/src/core_bridge/net/session.rs(N4 contract hook: park the initial guestMatchStartinpending_start),netplay-plan.md(this entry). - N5 handoff (see also N3's
net_leave_to_title/NET_OVERLAY_ZINDEX):winner_textNet arm currently returns"OPPONENT WINS"(both roles). Refine as needed;winner_text(winner, p1, p2)already carries both seats.start_net_match(world: &mut World, rule: AttackRule, local_side: Side, seed: u64, delay: u8)andend_net_match(world: &mut World). N5's Host "start" button: callstart_net_match(world, rule, Side::Left, wall_clock_seed(), session.input_delay)(it flips the session itself; it honorsTETRIS_SEEDwhen set, so leave theseedarg to the caller for real play and let the harness pin it). Guest needs nothing — the mirror is built fromMatchStart.- Rematch: host calls
start_net_matchagain (re-arms its own mirror and resendsMatchStart); the guest auto-rebuilds viapending_start→guest_pending_start_systemwithin ≤1 frame, resetting to the new seed at tick 0. Do NOT route guest Rematch throughstart_versus/start_net_matchlocally (guest must never reseed locally), and R is already suppressed inInMatch. Local-seat controllers (Humanfor a duel) are preserved across rebuild; the remote seat is always forced toController::Net. - N6 bot-vs-bot-across-the-wire: arm each peer's local seat to
Bot(hostBot+Net, guestNet+Bot) —versus_bot_systempushes the local queue and the lockstep transports it with zero special-casing. The local controller followsNetSession::role, not astart_net_matcharg.
- depends_on: [N4]
- location:
screens_menu.rs(also refines theNetarm inwinner_text),settings_persist.rs(additive helpers for a separatenet_profilefile) - description: Extend the
VersusFlowstage machine with anOnlineFlow: Title → "1 v 1" gains a Local/Online axis; Online → Host (statusListening, shows port + connect hint; local-IPv4 hint via the connect-to-publicUdpSockettrick with a fallback string when no route exists; "waiting for challenger…" onReady; Esc here callsnet_stop()— un-listens and frees the port, then back) or Join (text-entry widget: charset digits/dots/colon, backspace, Enter submits; Bevy 0.19 clipboard paste only if it works without touchingmain.rs, else keyboard-only — verify and log); status line perNetStatus(connecting/handshaking/ready/waiting-for-host — the last covers the guest-at-Ready-before-host-rule-pick gap). Host rule/opponent picker (rule picker reused; right side forcedNet) →start_net_match→MatchStart. Overlays (winner-overlay spawn/visibility patterns, N3 teardown contract, explicitZIndex):BindFailed("port in use"),VersionMismatch,JoinTimeout/PeerLost(frozen boards behind, "Connection lost — host offline or match full" for timeouts),Desync {tick}("desync at tick N — match aborted"),ByeReceived("opponent left"). Mid-match exit: Esc duringInMatchopens a confirm overlay ("Leave match?" Leave/Stay) instead of the pause chord — pause chord gated off inpause_chord_systemforInMatch(lockstep has no authoritative pause); Leave sendsBye+ teardown. Rematch: host-only (role-gate theVersusRematchButtonfor guests) and routed throughstart_net_match(freshMatchStart), neverstart_versus— the local reseed instart_versuswould fork the guest's mirror. Guest side: the rematch button is hidden; the guest waits for the host'sMatchStart. Persistnet_profile.json(last join address, prefilled in the entry) viasettings_persisthelpers as a separate resource —Settingsinstate.rsmust not be reshaped (T1 contract,state.rs:1-3). Esc walks OnlineFlow stages; entering Online never disturbs solo/local-versus flows (their stage tests pass untouched). - validation: headless-App menu-flow tests per stage transition
(mirroring T26's flow tests);
winner_textNet-arm test; overlay trigger-and-teardown tests perNetEventvariant, incl. the recorded visibility/click regressions' patterns (overlay buttons clickable while the frozen versus HUD is visible); entry edit-logic unit tests (charset, backspace, IPv4:port accept/reject);net_profileround-trip test. Author sign-off gate (author's rule: agent runs the checklist, posts, stops): two machines play a full garbage match over LAN + internet; default delay feels responsive. clippy/fmt/test gates green. - status: Completed
- log:
- Keyboard-only IP:port entry as specified. Clipboard finding: Bevy 0.19
removed clipboard support entirely (no
clipboardsurface anywhere inbevy_window/bevy_winit0.19.1 — verified against the vendored sources), so paste is unavailable with or withoutmain.rs; the entry is keyboard-only by platform, not by shortcut. Charset digits/dots/colon (Shift+;→:), Backspace/Delete, Enter/NumpadEnter submit, 44-char cap;parse_join_addraccepts trimmedIPv4:portwith port ≠ 0. - Local/Online axis realized as a sibling Title "Online" entry (marker
OnlineButton, spawned inbuild_menu_ui, owned panel flow inonline_ui.rs) rather than a stage inside the 1v1 submenu — every existing T26VersusFlowstage test passes untouched, satisfying the "never disturbs solo/local-versus flows" contract. - Wiring gates in
screens_menu.rs:pause_chord_systemreturns early whileNetStatus::InMatch(Esc belongs to the leave-confirm; lockstep has no authoritative pause);versus_button_clicksskips Rematch/Menu whileInMatch(host re-arms throughstart_net_match— never the local-reseedstart_versus— Menu runsnet_leave_to_title); guestVersusRematchButtonhidden (role gate); winner headline role-aware vianet_winner_textonce a seat isController::Net;TitleRoothidden while the online flow is open (same discipline as the 1v1 submenus, per the recorded click-swallow regressions). OnlineUiPluginmounts fromMenuScreensPlugin::build(single canonical mount). Overlay roots carry explicitNET_OVERLAY_ZINDEXso their buttons pick over the frozen-but-visible versus HUD (teardown contract respected:SimPausedheld,VersusMatchstays active until Back to title runsnet_leave_to_title).net_profile.jsonis a separate resource (NetProfile) with its own load/flush/exit systems chained beside the settings ones;Settingsinstate.rsuntouched (T1). Last-submitted join address persists and prefills the entry.- Cross-module seam: the screens wiring names
crate::core_bridge::net::*(themod net;-private blocker posted to the board); unblocked by N6's one-linepub(crate) mod net;incore_bridge/mod.rs. - Tests: 31
online_ui(14 pure entry/status/text logic + 17 headless flow/overlay/teardown) and 4 newscreens_menu(Title button stage transitions with title-hide, no-cross-talk with solo/versus, role-aware headline ×4 role/winner combos, local headline unchanged); existing T26 suite passes unmodified. Pending author sign-off: two-machine LAN + internet playtest and delay-feel check (an agent cannot operate two machines; request: host on machine A, join B→A over LAN for one full garbage match, repeat over internet, confirm defaultTETRIS_NET_DELAYfeels responsive, plus mid-match leave from each end).
- Keyboard-only IP:port entry as specified. Clipboard finding: Bevy 0.19
removed clipboard support entirely (no
- files edited/created:
crates/tetris-app/src/core_bridge/net/online_ui.rs(new — flow machine, entry, overlays, session watcher, mountable plugin),crates/tetris-app/src/screens_menu.rs(Online title entry,InMatchpause/rematch/menu gates, role-aware headline, title-hide, plugin mount),crates/tetris-app/src/settings_persist.rs(NetProfile+ load/flush/exit helpers + tests)
- depends_on: [N4]
- location:
core_bridge/net/harness.rs(new, production env-var path +#[cfg(test)]E2E),core_bridge/net/mod.rs,core_bridge/mod.rs(harness startup hook likeONE_V_ONE_ENV),.github/workflows/ci.yml - description: Two deliverables, because CI cannot run the real
binary (verified:
main()unconditionally addsDefaultPlugins; no display onubuntu-latest;CARGO_BIN_EXEspawns would crash on winit — an xvfb full-binary CI job is explicitly out of scope for v1, logged as a follow-up candidate):- CI E2E test (
#[cfg(test)]inharness.rs): twoMinimalPluginsapps in one process over real renet/netcode UDP on loopback — real session FSM, real lockstep driver, realMatches, host side driven byversus_bot_system(Botfor left), guest side by aBotfor right — a full garbage match to a crowned winner, asserting both sides' per-60-tickSnapshotHashstreams equal throughout and final snapshots equal, both apps shut down cleanly. Plus aTETRIS_NET_FORK=guest:<tick>test-only lockstep hook: one flipped input action at a tick → the equality assertion must fire (proves the test isn't vacuous). Port strategy per N2's fixed-test-port rule; mark#[ignore]-parallel-safe (serial with the N2 socket test if needed). - Desktop manual harness
TETRIS_NET=host:<port>/TETRIS_NET=join:<addr>env modes mirroringTETRIS_1V1(fullDefaultPluginsapp, human-run only): same bot-vs-bot-across-the-wire, logsNET match_done seed=… ticks=…,NET final_hash left=… right=…, exits after 2 matches (garbage, race); anyDesync/Lost/120 s stall → exit 1. Used for the N5 sign-off gate and real-NAT testing. CI: the E2E rides the existingcargo test --workspacejob (no new job, no xvfb); add it to the nightly soak list note if flaky-skip is ever needed.
- CI E2E test (
- validation: locally:
cargo test -p tetris-app net::harnessgreen, fork-injection variant fails as designed;TETRIS_NEThost+join on one machine via loopback complete 2 matches with identicalfinal_hashand exit 0; on two machines over the LAN the same holds (author runs). - status: Completed
- log: Commits
3581a62+668a530. CI E2E: 2 non-ignored tests (harness.rs) — twoMinimalPluginsapps over real renet/netcode UDP on loopback, OS-assigned port 0 (no contention): bot-vs-bot Garbage to a crowned winner with both peers' per-60-tickSnapshotHashstreams equal throughout + final snapshots equal; and fork injection (TETRIS_NET_FORK=guest:<tick>hook) →Desync{119}on both peers +SimPausedfreeze + provably divergent independently-recorded streams (non-vacuity). Rides existingcargo test --workspace; no ci.yml change needed. Desktop harness:TETRIS_NET=host:<port>/join:<ip>:<port>bot-vs-bot (Garbage → Race{40}),NET match_start/match_done/final_hash/ complete/faillogging, exit 1 on desync/stall/loss, exit 0 on success; startup hook incore_bridge/mod.rsmirrorsONE_V_ONE_ENV. LIVE two-process X11+NVIDIA verification: identical winner/ticks/final_hash on both peers across 2 matches (clean, exit 0 both sides); fork run → "desync detected at tick 119 — match frozen", both processes exit 1. Three harness lifecycle bugs found only by running live: guest rematch counting re-keyed by match seed; phantom 0-tick host rematch log;std::process::exitmid-frame SEGFAULT on winit/GPU atexit → success usesAppExit::Success, failurelibc::_exit(1)on unix. Also landed thepub(crate) mod net;enablement N5 needed. Routed follow-ups (beyond N6 ownership): (1) real production bug —guest_net_systempre-match drain reads the whole reliable channel on theInMatch-transition frame and discardsTickBatches queued behindMatchStart→ guest stalls at tick 0 under load; harness holdsSimPausedbriefly afterMatchStartas a workaround — root fix done as a post-N6 fixup (see below); (2) systemic test flake: N2/N3/N4/N5 UDP tests sharingTETRIS_TEST_NET_PORTcontend in parallel (--test-threads=1clean) — port-0 bind + read-back fixup. Desktop-harnessMATCH_START_HOLDrevisited once the drain bug is fixed. - files edited/created:
crates/tetris-app/src/core_bridge/net/harness.rs,crates/tetris-app/src/core_bridge/mod.rs - post-N6 fixup (
f9d6d51): drain bug root-fixed —guest_net_systemre-checks status each drain iteration (while matches!(status, Handshaking|Ready)), never consuming past theInMatchtransition; symmetric guard added to the hostHellodrain; consume-on-read rule in module docs. Deterministic regression testmatchstart_transition_frame_does_not_swallow_queued_tickbatches(RED: guest tick-0 stall; GREEN: all queued batches replayed in order, snapshot-equal).MATCH_START_HOLDremoved; E2Es start live (every run crosses the old stall window). UDP tests moved to port-0 + read-back (TETRIS_TEST_NET_PORTdeleted;TEST_NET_LOCKkept for the occupied-port probe). 3× parallel + serial full-suite green; systemic flake resolved.
-
depends_on: [N6]
-
location:
core_bridge/net/harness.rs(soak extension),core_bridge/net/protocol.rs(fuzz tests),netplay-plan.md(audit log) -
description: No FP-risk audit needed (core verified integer-only). Instead: (a) extend the E2E into a 20-match soak (alternating Garbage/Race, seed sweep, one very long Race-to-40 with garbage-storm
MAX_GARBAGE_PER_LANDchurn), diffing per-tick hash streams across the two in-process mirrors — end-to-end validation of serialization + plumbing + lockstep under sustained load, tagged#[ignore]for the nightly job liketests/soak.rs; (b) proptest byte-fuzz overprotocol::decode: no panic, onlyProtocolError(host must never fall over on hostile bytes — relevant because auth is Unsecure); (c) record the audit result in this plan's log, including the two-machine desktopTETRIS_NETsoak if the author ran it. -
validation: 20-match soak green locally and on CI nightly (
--ignored), zero hash divergence over all matches; 10k-case decode fuzz with no panic; audit conclusion written here. -
status: Completed
-
log: (b) Decode fuzz — green, normal
cargo test(protocol.rstests,cases = 10_000):fuzz_decode_never_panics(pure-random ≤140 B blobs + truncate/byte-flip/append mutations of valid encodings incl. fixed variant-table bases),fuzz_roundtrip_any_msg(10k generated msgs, exact roundtrip),fuzz_variant_truncations_exhaustive(deterministic every- prefix × garbage-tail walk). Oracles: decode never panics, failure modes areProtocolErroronly, and every accepted buffer re-encodes canonically (encode(decode(b)) == b) — the wire can never carry non-canonical forms of what the host then trusts. Separate bincode-1.3 probe: hugeVec/Stringlength prefixesErrelement-wise (no pre-alloc, no OOM abort). ~0.05 s per 10k-case suite; config proven live (env override to 100k → 0.47 s).(a) Soak —
netplay_soak_20_matches(#[ignore],core_bridge/net/harness.rs; since game-modes T22 extended and renamed tonetplay_soak_20_matches_per_rule— 20 matches per attack rule incl. Dig Duel and Switch): 20 matches over one connected pair on real UDP loopback at 24× virtual speed, alternating Garbage / Race-to-40 (DEFAULT_RACE_LINES), rotated-mix seed sweep, rematches through the wireMatchStart(production path, first-ever automated coverage), per-60-tickSnapshotHashstreams recorded independently of the wire check and compared on every shared tick label + final snapshot equality + all drainedNetEvents benign. Results (this session): debug 57.7 s —ticks=83014, hash boundaries compared=1371, garbage sent=248 landed=239 in 136 landings, MAX_GARBAGE_PER_LAND cap hits=12, longest Race=10608; debug rerun after final tweaks 74.7 s —ticks=107455, boundaries=1779, sent=239 landed=233, cap hits=6, longest Race=13002; releasecargo test --release -p tetris-app -- --ignored51.2 s — 20/20, zero hash divergences. Nightly rides CI (nightly-soakjob got system-deps install +cargo test --release -p tetris-app -- --ignoredstep; timeout 75→95 min). Manual one-liner:cargo test -p tetris-app --release -- core_bridge::net::harness::tests::netplay_soak_20_matches_per_rule -- --ignored --exact --nocapture.Soak driver decision: pure
Controller::Botseats cannot supply the churn/long-Race dimensions in a net match — the bot plan-stepper assumes same-tick action application (local-bridge contract) and wedge-aborts into spawn hard-drops when actions landDticks late (measured: 0 line clears, ~15 locks/match, garbage never sent; probe since removed). Production net matches never have Bot seats → harness play-quality gap, NOT a netplay bug; production untouched. The soak instead drives both seats withsoak_player_system: scriptedbot_movebursts (rotate→slide→hard-drop) pushed through the realVersusActions→schedule_local→TickInput/TickBatchpath with the negotiated delay — i.e. exactly the human input path. The N6 E2Es keep Bot seats (wiring pins, untouched). Legal-Race-ending assertion relaxed (coresettlecrowns on top-out under any rule, not only 40-line finishes). Per-match hash streams are reset seed-keyed inside the recorder so a one-frame-lagging peer's last old-mirror boundary can't leak into the next match's stream.Production bug found + fixed (soak-proven): a mid-match
MatchStart(rematch) parked by the lockstep's InMatch drain swallowed the new match's already-queuedTickBatches — the guest's old-mirror stale check (tick < self.tick) discarded them while renet had already consumed them from the reliable stream → guest stalled at tick 0 forever (soak run-1:host InMatch tick 214909, guest InMatch tick 0 stalls 214922; only reachable via a fast rematch — the desktop harness's single rematch raced by luck). Fix:NetLockstep::staged_batches— batches arriving while a start is pending are staged (deduped) and adopted intobatch_bufferbyguest_pending_start_systemtogether with the mirror rebuild (lockstep.rs,versus.rs); regression testrematch_matchstart_does_not_swallow_the_new_match_tickbatches(harness, non-ignored; RED pre-fix: guest tick-0 stall; GREEN post-fix). The thin real-transport lockstep test now registers the same production consumer (it mirrors production's contract: a pending start always has one).(c) Unsecure-auth posture (host never falls over; what v1 accepts): fuzz guarantees hostile bytes can only yield
ProtocolError/canonical accepts — no panic, no OOM, no non-canonical state. Pre-handshake ignore VERIFIED in code: whileHandshakingthe host drains the reliable channel and processesHelloonly —session.rs:654(debug!("net: ignoring {other:?} before handshake")) — and the lockstep host system isInMatch-gated, so pre-authMatchStart/TickInput/SnapshotHashcannot reach game state. Residual accepted risks (v1, documented): (i) a connecting peer that stops atHellocan occupy the host's single slot indefinitely (no host-side handshake timeout) — self-DoS of the host's own lobby; (ii) post-handshake the peer is trusted: falseSnapshotHashes freeze the match via the desync path (both peers freeze symmetrically — an announcement the honest side cannot ignore, but no state fork), stale/queuedTickInputs after a rematch simply fire later in the new match, and host-authoritative input application means the guest can only ever degrade its OWN mirror's consistency (host is the clock; wrong guest inputs arrive as empty or shifted, never fork the host); (iii)Bye+ netcode-level disconnects remain available to an authenticated peer — matches are explicitly short-lived open sessions (README warning). No auth tokens in v1 by design (N2); these risks are the documented price. Two-machine desktopTETRIS_NETsoak: not run this session — the N5 author sign-off checklist (LAN/internet feel) stays open. -
files edited/created:
crates/tetris-app/src/core_bridge/net/ harness.rs(soak + soak player + regression test),crates/tetris-app/src/core_bridge/net/protocol.rs(fuzz),crates/tetris-app/src/core_bridge/net/lockstep.rs(production fix:staged_batches+ docs + thin-test wiring),crates/tetris-app/src/core_bridge/versus.rs(production fix: staged adoption inguest_pending_start_system),.github/workflows/ci.yml(nightly app-soak step), this file.
- depends_on: [N5, N6]
- location:
README.md,PRD.md,CHANGELOG.md - description: README: Features bullet (online 1v1 lockstep, delay-based),
Controls note (host = left/P1 preset, guest = right/P2 preset; no pause in
net matches, Esc = leave-with-confirm), env-var table rows (
TETRIS_NET,TETRIS_NET_DELAY,TETRIS_NET_FORK), "Playing online" section (port-forward/NAT caveat + firewall note, "match is open while listening — keep sessions short" warning). PRD: §4 multiplayer non-goal struck → reference the implemented §15 scope; §15 marked for local+online versus, keeping lobby/relay/discovery/rollback/session-tokens as post-v1. CHANGELOG unreleased entry; tetris-plan.md note that T24–T26 + N1–N8 form netplay v0.1. - validation: prose review; docs-only diff; links/anchors resolve.
- status: Completed
- log: Docs-only commit
72baf12. README: intro + Features bullet for local/online versus; Controls blockquote (host/left = P1 WASD, guest/right = P2 arrows; no pause in net matches, Esc = leave-with-confirm); "Playing online" section (Host/Join flow, UDP port shown on Host screen, keyboard-onlyip:portentry — Bevy 0.19 has no clipboard API, D=max delay ≈133 ms, NAT port-forward + firewall caveat, "port is open while listening" warning, no-lobby/relay note with the "host offline or match full" wording, version-handshake refusal, desync freeze); env table rows forTETRIS_NET,TETRIS_NET_DELAY(default 8, clamp 2..=30),TETRIS_NET_FORK— all fact-checked against code (harness.rs:87/91, session.rs:104-113/623, online_ui.rs). PRD: §4 multiplayer non-goal struck → §15; §15 retitled "Versus (netplay v0.1) & Future Extensions" splitting implemented local+online versus from post-v1 (lobby/relay/discovery, rollback, session tokens stay non-goals); §1/§13 pointers updated. CHANGELOG[Unreleased]entry: netplay v0.1 = T24–T26 + N1–N8. tetris-plan.md: netplay v0.1 note. Doc drift found+fixed en route: N5's "44-char cap" claim (code: 45); stale pre-versus README/PRD claims. Author sign-off (two-machine LAN/ internet, delay feel) still open — checklist in N5 log. - files edited/created:
README.md,PRD.md,CHANGELOG.md,tetris-plan.md
| Wave | Tasks | Can Start When |
|---|---|---|
| 1 | N1 | Immediately |
| 2 | N2 | N1 |
| 3 | N3 | N2 |
| 4 | N4 | N3 |
| 5 | N5, N6 | N4 (disjoint files after the N4→N5 winner_text handoff: screens_menu.rs vs net/harness.rs + core_bridge/mod.rs hook + CI) |
| 6 | N7, N8 | N6 / N5+N6 |
Netcode is inherently sequential in the core path (N1→N4); the only safe parallelism is N5∥N6 (wave 5) and N7∥N8 (wave 6).
- Protocol: round-trip + malformed-decode unit tests (N1); byte-level proptest fuzz with no-panic guarantee (N7).
- Session:
NetStatustransition-table tests; in-process two-MinimalPlugins-App loopback connect/teardown test (N2) — the same fixture style the repo already uses for headless app tests. - Lockstep: fake-
NetOutseam tests (delay math, empty ticks, stall, late-input drop, desync detection, SimPaused freeze) + the same-batch-stream proptest (N3). - End-to-end: two in-process apps over real netcode UDP loopback,
bot-vs-bot to a crowned winner with per-tick hash equality + non-vacuous
fork injection (N6, in CI);
TETRIS_NETdesktop harness for real-network and two-machine sign-off (N6/N5). - Soak: 20-match ignored-tagged nightly (N7), alongside the existing core soak.
- Regressions: all existing tests stay green unmodified (exact counts are a moving target — do not pin them); solo + local-versus gate-matrix tests (N4) guard the paths netplay bypasses. Rule bugs reproduce headlessly via the N3 seam first (repo discipline). Human gates follow the repo's author-sign-off rule.
- ReliableOrdered head-of-line stalls: a lost batch packet stalls the guest mirror until retransmit; past ~RTT it shows as a hitch. Accepted for v1 (~2-4 KB/s at 60 Hz); mitigation path (Unreliable + NACK + re-request) explicitly deferred.
- Netcode auth is Unsecure (v1): anyone reaching the port with the right
PROTOCOL_IDcan complete the handshake; the decode fuzz (N7) guarantees hostile bytes can't crash the host; the host additionally ignores all gameplay messages before handshake, and the Host screen warns the match is open while listening. Session tokens/invites deferred. - Second-guest silent drop (netcode
max_clients: 1): noMatchFullsignal exists; surfaced as theJoinTimeoutmessage wording ("host offline or match full"). - Renet 2.0 API surface unverified at plan time:
Cargo.lockhas no renet entries yet; N1's spike must pin every API this plan's N2/N3 code against and record it innet/mod.rsdoc notes before wave 2 starts — not just the codec. - Input-delay misalignment:
Hello.delay+MatchStart.match_delaynegotiateD = max(peers)so a host/guestTETRIS_NET_DELAYmismatch silently widens the delay instead of dropping inputs. - Lone-human preset override hijack:
lone_human_p1must treatNetas an occupied seat (N4.5) or the host loses WASD + solo-alternate keys; pinned by dedicated preset tests. - Rematch local reseed:
start_versusreseeds locally (verifiedcore_bridge/versus.rs:143-168) — guest Rematch must be hidden and host Rematch routed viaMatchStart; otherwise mirrors fork silently. - Overlay/HUD visibility regressions: the repo has recorded bugs for
active-versus click swallowing and overlay ZIndex; N3's teardown contract
- N5's clickable-overlay test are mandatory, not optional polish.
- Pause semantics: pause chord off in
InMatch;SimPausedis the only net-mode freeze (Lost/Desync); mid-match exit is Esc-confirm →Bye. All three stated, gated in named systems. - Headless CI limits: full-binary CI E2E impossible without xvfb + audio workarounds — out of v1 scope; coverage comes from in-process real-transport tests + manual desktop harness.
- Port collisions in tests: fixed test ports (env override
TETRIS_TEST_NET_PORT) with serial execution against the N2 socket test; documented. Controllerenum exhaustiveness: the only external breakage site iswinner_text; handled by N4's carve-out arm and clippy-D warnings.
The plan's "direct IP/port" story assumed the host can get a reachable UDP port; the v0.2.0 release shipped the manual port-forward docs only. Two no-infrastructure alternatives were verified as dead ends in 2026:
- ngrok 3.39.2 has no
udpcommand — raw UDP tunnels were removed in v3;ngrok --helpon the dev machine shows onlyhttp,tcp,tls. - cloudflared 2026.9.3 rejects UDP origins — verified live:
Error validating origin URL: Currently Cloudflare Tunnel does not support udp protocol.
The transport is raw UDP (bevy_renet/netcode) and guests already work with
outbound-only connectivity, so the answer for the other half is the host's
own router: UPnP IGD AddPortMapping (near-universal on consumer
routers), giving true direct P2P with zero router clicks.
Design (shipped, core_bridge/net/upnp.rs — zero new dependencies):
SSDP M-SEARCH for InternetGatewayDevice:1 (2 s timeout, replayed every
500 ms) → fetch the device description → targeted-scan the XML for the
WANIPConnection:2 (fallback :1) controlURL (resolved against the
LOCATION) → SOAP AddPortMapping (UDP, port N:N, internal client = the
existing connect-to-public UdpSocket LAN-IPv4 trick, description
blockfall-netplay, lease 3600 s) → GetExternalIPAddress for the
Friends join at <ext_ip>:<port> share line. Every protocol byte decision
is a pure function over canned fixtures (SSDP burst → LOCATION, device
XML → controlURL, SOAP fault code → human text) plus a thin IO glue layer;
the whole run executes on a background std::thread (never a frame) and
reports through an mpsc mailbox polled by upnp_driver_system. A generation
counter drops results that raced a teardown. Lease renewal re-AddPortMappings
on the stored control URL at 30 min (1 packet — discovery is not repeated)
while the session still holds; renewal failures keep Mapped (a live match
must not hear about a probe blip; the existing NAT hole survives anyway).
Teardown: net_stop paths drop the session to Idle, the driver system
watches that edge and best-effort DeletePortMappings on a bounded-timeout
thread; app exit attempts one synchronous delete — SIGKILL leaves the
self-expiring 1 h lease, which is exactly why the lease is finite and
renewed. SOAP faults become reasons (718 port conflict, 725 permanent
leases only, 726 no such entry, 402 not a WAN connection, else generic).
UI: the Host screen's status line runs Opening router port… →
Friends join at <ext_ip>:<port> → on failure
UPnP unavailable — forward UDP <port> manually (see README) (one line,
never blocks play — LAN is unaffected). U toggles the attempt
(persisted as NetProfile::upnp_enabled, additive serde default true —
Settings stays unreshaped per the T1 contract).
Testing: canned-fixture parser tests, transition-table + lease-math tests,
and a fake IGD (std TcpStream loopback server serving a device
description + SOAP responses incl. an HTTP-500-carried 718 fault) that pins
the full client's wire format. No unit test sends real SSDP multicast — the
real client is behind an injectable runner seam. Live probe on the dev
machine (2026-09-29): no gateway answered SSDP on that network (documented,
not gate-blocking); the UPnP-enabled-router sign-off stays with the author.
2026-09-30 follow-on: UPnP turned out to be the minority path — the hosted introduce/relay netplay gateway with 5-character room codes now carries cross-WAN play and is shipped; see gateway-plan.md (G1–G5).