Generated: 2026-09-30. Follow-on to netplay-plan.md (N1–N8 shipped, v0.2.0
released) and its post-plan UPnP addendum (a2c71a0).
Direct IP join works on LAN and through UPnP-mapped routers, but nothing else
does. Hosted UDP tunnels are dead ends (verified 2026-09-29: ngrok 3.39
removed udp; cloudflared 2026.9.3 rejects UDP origins). The locksync load
is ~2–4 KB/s per peer, so a relay on our own opensensor box is both the
always-works answer (needs only outbound UDP, which is universal) and absurdly
cheap (~3 Mbit/s for 100 concurrent matches). Rust over Go: ~1–2 MB vs ~6–10
MB RSS, static musl binary, and the wire codec lives in one crate shared by
game and gateway (no format drift, tests run both sides).
Single machine, single process, zero external dependencies, one IPv4 UDP control port + a data-port range. No DB, no persistence, no TLS, no game knowledge — netcode packets are opaque ciphertext to the gateway.
Two packet classes on the control port, demuxed by first byte:
0x2A (*, ASCII) = control; any other first byte = game data. (G1
correction: renetcode 2.0.0's wire first byte is the prefix
packet_type | (sequence_bytes << 4) with packet_type in 0..=6 —
not "0..5 and 0xFF"; 0x2A's low nibble 0xA is outside 0..6 either way,
pinned by test against the resolved crate sources.)
Control frames (packed, big-endian, all #[non_exhaustive]-free plain
structs, manual encode/decode, strict length checks → Err(WireError),
never panic):
*R <code:5B> <game_port:u16> host register/keepalive (every 2 s)
*A <code:5B> ack
*G <code:5B> guest lookup / re-lookup (idempotent)
*F <code:5B> <host_ip:4B> <vport:u16> guest found: connect host_ip:vport
(relay path) — ip is the OBSERVED
host addr (for direct-race v2)
*B <code:5B> busy (room paired to a different guest)
*E <code:5B> no such room
*D <code:5B> host explicit release (on net_stop/exit)
*S <code:5B> slot exhaustion (G1: gateway full / bind
failed on *R — retry later; host regenerates
nothing, the code is simply not created)
Room codes: 5 chars from the confusion-free alphabet
ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no I L O 0 1), generated client-side;
gateway validates membership, case-normalizes.
*R: new room → allocate virtual data port V fromdata_port_start + slot(default range27017..=27999, 983 slots); bind data socket for V on demand. Re-*Rfrom same (ip, code) = keepalive (refresh). Re-*Rfrom different ip for a live code →*C <code>collision reply (client regenerates).*G: unknown/expired →*E. Paired to another guest addr →*B. Otherwise pin (guest ip, port-from-packet? no — guest data arrives later from renet's own socket; guest slot = any first game-data source on V that isn't the host), reply*F. Re-*Gfrom same ip while paired to that ip → idempotent*F(retry-friendly).- Data plane on V: from host ip → forward to pinned guest (or hold until
first guest packet — the netcode connect starts from the guest, so
host→forward with no guest yet = drop). First non-
*packet on V from a non-host source pins the guest and forwards. Guest→forward to host ipgame_port. - GC (1 s tick, virtual clock): unpaired room expires 15 s after last
*R; guest slot released 10 s after last data from guest (room falls back to listening); paired room fully expires 15 s after last host*R/data. Expired → close V socket, free slot. *Dfrom host ip → immediate teardown, code reusable.- Rate limit: per-source-IP token bucket, 10 burst / 5 s refill, applied to all control frames (drop over).
- Config:
TETRIS_GATEWAYenv (defaultnetplay.opensensor.xyz:27016; empty string disables all gateway UI/behavior). - DNS on join path (new, also fixes the trycloudflare-shaped gap): a
background-thread resolver + mpsc polled on
Update(same driver shape as theupnp.rspattern — never block the main thread). - Host: when
NetStatus::Listeningand gateway enabled, control client binds an ephemeral UdpSocket, sends*Rimmediately + every 2 s (a fixed-step or timer system gated onListening), stores the room code; onnet_stop/teardown sends*D(best effort, ≤200 ms). Host screen showsRoom XXXXXalongside the UPnP line. - Guest: Join screen gains Code mode (5-char alphabet entry,
backspace, Enter) alongside IP mode. Flow: resolve gateway →
*G→*F→net_join(gateway_ip:vport)reusing the entire existing client FSM unchanged;*E→ "no such room";*B→ "match full" (better UX than today's JoinTimeout silence); error/status lines per step; Esc returns. max_clients: 1interplay: gateway*Bfires only when paired; a race between two guests through the relay still ends in the existing netcode behavior for the loser — acceptable, logged.
Lobby list, auth/accounts, IPv6, TLS, persistence, direct-race
(guest tries host_ip first, falls back to vport after 2 s — the *F
reply already carries the pieces), hole punching.
G1 ── G2 ── G3 ──┐
└──────────── G4 ┴─ actual: G2 ∥ G4 (wave 2), then G3, then G5
Wave: 1 2 3
- depends_on: []
- location:
Cargo.toml(workspace members),crates/netplay-gateway/{Cargo.toml,src/lib.rs,src/wire.rs,src/room.rs,src/main.rs},crates/netplay-gateway/README.md(tiny ops doc) - description: New workspace crate, zero dependencies (std only;
lib + bin targets).
wire.rs: frame encode/decode with strict length and alphabet validation,*R/*A/*G/*F/*B/*E/*C/*D, plus the control-vs-data demux test (no netcode type byte is 0x2A).room.rs: pureGatewaystruct —on_packet(src, bytes, now) -> Vec<(dst, Vec<u8>)>on_tick(now) -> Vec<PortEvent>implementing the full room/GC/busy/ collision/rate-limit state machine; port allocator with aPortAllocatortrait (fake in tests, real bind/close inmain.rs).main.rs: arg parsing (listen0.0.0.0:27016, data range, idle secs — env or flags, keep trivial), two-socket event loop (nonblocking poll: control socket + live data sockets, 100 ms tick), logging via plain eprintln on start/collision/GC summaries, SIGINT graceful close (best- effort*Disn't the gateway's job). RSS target < 2 MB under 1k rooms.
- acceptance: wire round-trip + rejection tests; state machine covers
register/collision/lookup/notfound/busy/idempotent-relookup/guest-pin/
forward-both-ways/GC-unpaired/GC-paired/guest-slot-release/
*D/ rate-limit — all virtual-time, zero sockets; one loopback integration test (real sockets, port 0 + fixed data range) pairing two UdpSockets through the gateway.cargo test --workspacegreen (existing 276+136+6 unchanged); clippy-D warnings+ fmt clean. - status: Completed (2026-09-30)
- log:
- TDD: wire + room tests written against stubs first — RED captured at 22 failed / 6 passed, then GREEN at 28 passed (+1 integration).
*S <code>added to the wire table (this file, above): distinct reply for slot exhaustion /PortAllocator::bindfailure on*R, per the plan's risk-section "busy reply with a distinct code (documented)".on_packetgained adst_port: u16parameter (the local port the datagram arrived on). Rooms are keyed by code for control and code+port for data — each room owns its virtual data port exclusively, which is the only sound way to attribute data-plane packets (source-IP attribution breaks for same-NAT hosts). Replies go tosrc, forwards to the pinned peer, always from the receiving socket so the relay's 5-tuple is stable per leg.- Virtual ports bind at
*R(not lazily at first data): same lifetime, andmain.rshas a pollable socket before any guest exists. - GC consolidated (matches plan wording): both windows key on host
activity (
*Ror host data, 15 s —--idle-secs); guest slot releases 10 s after last guest data → room falls back to listening.*Dfrom the host IP = immediate teardown (allocator closed synchronously;PortEvent::Closedsurfaces on the nexton_tick). - Guest pin = first non-host data source by full SocketAddr;
*Gidempotency/busy tracked per IP (plan wording; same-NAT second guest gets*Fthen loses the pin race → netcode timeout, as accepted in Risks). Keepalive*Rrefreshesgame_port(host restart on same IP reuses the code). - Rate limit: per-source-IP token bucket, capacity 10, refill 1 token /
500 ms (full 10 per 5 s, floor-tracked → 2 s keepalives never
deplete); applies to all control frames incl. malformed; silent drop;
Gateway::rate_limited()counter feeds the binary's ≥60 s summary. - 0x2A demux pin: exhaustive sweep of the renetcode 2.0.0 prefix space
(
type 0..=6×seq_bytes 0..=8→ first byte) — none equals 0x2A. Citedrenetcode-2.0.0/src/packet.rs:14-21,59-71,346-365; the plan's "0..5 + 0xFF" prose corrected above. renet's own packet-type bytes ride inside the encrypted Payload, never first on the wire. - No
--listen/idle env vars (flags only, per "keep trivial"); SIGINT/SIGTERM = std-default process exit, OS closes sockets, no persistence — documented in the crate README. std has no poll(): the loop drains nonblocking sockets toWouldBlockper 100 ms tick — ample at lockstep load. --self-test: in-process loopback relay on port-0-allocated control- data range; pairs host@127.0.0.1 / guest@127.0.0.2 through
*R/*A/*E/*F+ data both ways +*F-relookup +*B@127.0.0.3 +*D→*E; printsPASS/FAIL, exit code 0/1 (verified release build). NOTE for G2/G5 tests: host and guest must bind distinct loopback addresses (the relay distinguishes legs per-IP; on the WAN they always differ).
- data range; pairs host@127.0.0.1 / guest@127.0.0.2 through
- Gates:
cargo test --workspacegreen (276+136+6 unchanged, +28 lib +1 integration);cargo clippy --all-targets -- -D warningsclean (one plan-mandated#[allow(clippy::result_unit_err)]onPortAllocator::bind);cargo fmt --all --checkclean; release binary builds;--self-testpasses.
- files edited/created:
Cargo.toml(workspace members),crates/netplay-gateway/{Cargo.toml,README.md,src/lib.rs,src/wire.rs,src/room.rs,src/main.rs,tests/relay_loopback.rs},gateway-plan.md(this plan:*Srow + demux note + G1 status/log)
- depends_on: [G1]
- location:
crates/tetris-app/src/core_bridge/net/gateway.rs(new, module seam declared innet/mod.rsby the implementing agent — solo wave, no conflict),crates/tetris-app/Cargo.toml(+netplay-gatewaydep),crates/tetris-app/src/core_bridge/net/session.rs(only if a join-path hook is required — record any diff prominently) - description:
TETRIS_GATEWAYconfig parse (defaultnetplay.opensensor.xyz:27016, empty = disabled); background DNS resolver thread + mpsc pattern (mirrorupnp.rsdriver shape); control client resource: room-code generation (from the shared alphabet),*Ron Listening edge + 2 s keepalive system,*Don teardown; guest lookup:*G→ typed result (Found{ip, vport},NotFound,Busy,GatewayUnreachable,BadReply) delivered via mpsc;net_join_by_code(world, code)glue that resolves then calls the existingnet_join(SocketAddr)when Found. Systems allrun_if-gated on gateway-enabled; zero cost when disabled or Idle. - acceptance: unit tests for config parse + code generation
(alphabet membership, length) + reply classification; the whole flow
testable against an in-process real gateway via
netplay_gateway:: Gatewayfed with loopback UdpSockets (test, not production coupling); DNS resolver test using "localhost" only (no internet dependency); keepalive cadence +*Don teardown via the existing headless-App patterns; existing tests green; fmt/clippy clean. - status: Completed (2026-09-30) — including the root fix for the cross-cutting picking fragility surfaced while landing it (carve-out granted by main, board_3e8c9125; see log + separate commit).
- log:
- TDD: config/classification/resolver tests first, then flow tests
against the REAL in-process
netplay_gateway::room::Gatewayover loopback UDP (distinct loopback IPs per leg per G1's note). 27 tests: env parse (default/empty/custom), code alphabet+length, frame classification (*A/*F/*B/*N/*S/*C), literal-IP vs DNS resolve (localhost only), full host register→*A→Announced, keepalive cadence,*Don teardown, collision/retry, guest*G→*F→net_joinhandoff,*B/*N/timeout/unreachable paths, disabled- gateway zero-cost (no socket, no thread, no frames). from_env()enablement deviation (opt-in): spec maps unset env → default endpoint. HereTETRIS_GATEWAYunset ⇒ disabled instead, because this dev box has live internet and the existing headlessListeningtests would start sending real*RUDP to the production endpoint.parse_gateway_env(None)still returnsSome(DEFAULT_GATEWAY_ENDPOINT)(pure string map honored + tested); G3 arms the gateway viaGatewayConfig/NetProfileexplicitly.PreUpdatenotUpdatefor the driver system: any addedUpdatesystem perturbs Bevy's topo tie-break of two unrelated menu systems (pause_chord_systemvs the settings capture-clear sharingRebindingCapture) and flippedpause_chord_suppressed_while_ capturing. The driver is a pure poll (DNS mailbox + nonblocking control socket, reacts to previous-frameNetStatus) — one frame of latency immaterial. Documented in the plugin doc.- Guest join glue:
join_room(world, code)sends*Gafter resolving the endpoint (literal-IP sync, hostname on a generation-stampedstd::thread+ mpsc, mirroringupnp.rs); on*Fthe driver queuesnet_join_by_codecalling the existingsession::net_joinwithresolve(gateway_host):vport(vport is the GATEWAY's port per plan). Timeout 3 s;*B/*N/*S/*Cmap to distinctGuestLookupStates +NetGatewayEvents for the UI. - Teardown: Listening→Idle/Connecting edge sends
*Dfire-and-forget (200 ms budget on the nonblocking socket); room code kept for re-*Ron host restart (gateway keys by IP per G1). - PICKING FRAGILITY ROOT-CAUSED & FIXED (surfaced while landing; NOT
gateway logic; carve-out board_3e8c9125): Bevy 0.19 stores every
resource as a component on a hidden entity (
bevy_ecs-0.19.1/src/ world/mod.rs:1969), so ANY new resource type shifts global entity indices, andbevy_ui-0.19.1/src/picking_backend.rs:251-269computes pick depth from query-iteration order and IGNORESZIndexentirely — overlapping widgets tie and resolve by hash-bucket order, flippingone_v_one_submenu_clicks_reach_the_submenu_not_the_titleon any resource insertion (reproduced with a unitinsert_resourcein an empty plugin). Standardized mechanism (commit 2): hidden UI NEVER participates in picking —sync_hidden_ui_unpickable(PreUpdate, screens_menu.rs) marks every hiddenNodePickable::IGNOREand lifts it when visible; menu roots and text labels carryPickable::IGNOREpermanently so containers/text can never swallow clicks. Regression pinned bydummy_resources_cannot_reroute_submenu_ clicks(inserts dummy resources, replays the full 1v1 click-through). This is the 3rd picking incident (after T26'sZIndex(1)submenu fix and the click-through regression) — all three documented in thesync_hidden_ui_unpickabledoc comment. G3 needs NO wiring: its join-by-code buttons/panels inherit correct pickability viaVisibilityautomatically (spawn withmenu_button/label_node/add_menu_rootto keep the convention uniform). - Gates: 27 gateway tests green;
cargo test --workspacefully green (304/304 + 136 + 6 + 28; 4 consecutive parallel runs + single-thread; isolated online_ui parallel flakes = N6-documented systemic UDP-port contention board_730571a0, unrelated); clippy--all-targets -D warningsclean;cargo fmt --all --checkclean.
- TDD: config/classification/resolver tests first, then flow tests
against the REAL in-process
- files edited/created:
crates/tetris-app/src/core_bridge/net/gateway.rs(new: client, plugin, 27 tests)crates/tetris-app/src/core_bridge/net/mod.rs(+pub mod gateway;)crates/tetris-app/Cargo.toml(+netplay-gateway = { workspace = true }); rootCargo.toml(+workspace path dep, required forworkspace = true; parallel agent does not touch it)Cargo.lock(automatic)- session.rs hook (prominently recorded, 1 line):
NetPlugin::build()gains.add_plugins(super::gateway::NetGatewayPlugin)after theNetLockstepPluginline — the canonical mount point next to the other net sub-plugins; no other session.rs change. crates/tetris-app/src/screens_menu.rs(carve-out board_3e8c9125: picking-determinism fix + regression test, separate commit)gateway-plan.md(this log)
- depends_on: [G2]
- location:
crates/tetris-app/src/core_bridge/net/online_ui.rs,crates/tetris-app/src/settings_persist.rs(NetProfile: remembergateway_enabledbool default true — additive serde default;Settingsuntouched),crates/tetris-app/src/screens_menu.rsonly if a stage transition forces it (record it) - description: Host screen: while Listening + gateway enabled, show
Room XXXXX — share with a friend(fallbackgateway offlineline, one line, never blocks LAN play; UPnP line still renders — both can be true, room code wins display priority when present). Join screen: mode toggle Code ⇄ IP (default Code when gateway enabled), 5-char entry widget (reuse the IP-entry machinery: charset = the room alphabet, backspace, Enter submits), per-step status (resolving… / joining room… / no such room / match full / gateway offline — reuse existing overlay/ timeout copy where it fits), Esc walks back;net_join_by_coderesult feeds the existing Connecting→…FSM. Host teardown (Esc, net_stop, leave-to-title) releases the room (*D). - acceptance: headless-App flow tests per transition (mirror existing
N5 patterns incl. the
Listening | BindFailedtolerance convention); entry-widget unit tests (alphabet, backspace, reject-6th-char); gateway-disabled config hides Code mode; overlay/teardown contracts unchanged (NET_OVERLAY_ZINDEX etc. untouched); existing 29 screens_menu- 31 online_ui tests untouched and green; fmt/clippy/test gates.
- status: Completed (2026-09-30)
- log:
- TDD: 22 pure/unit tests written first against the not-yet-existing API
(RED = 96 compile errors:
code_char,code_push,key_to_code_char,parse_room_code,CodeEntry,JoinMode,host_room_text,lookup_status_text,join_status_text,ROOM_CODE_ALPHABET,NetProfile.gateway_enabled), then implemented, then 11 headless-App flow tests against the REAL in-processnetplay_gateway::Gatewayover loopback (legs on 127.0.0.1/.2/.3 per G1's distinct-loopback rule). - Pure layer:
ROOM_CODE_LEN=5+ the wire crate's 31-char alphabet (noI L O 0 1);code_pushnormalizes lowercase, rejects the 6th char;key_to_code_charmaps letters + digits/numpad2..=9only;parse_room_codestrict 5-char;CodeEntryrequest/one-shot-submit latch mirrorsJoinEntry;JoinMode::{Code,IP}witheffective_join_mode(Code only when gateway endpoint ANDgateway_enabledprofile bit — toggle button inert, labeledgateway off — join by IP, when unavailable). - Shipped strings: Host share line
Room {CODE} — share with a friend(Announced) /Room {CODE} — announcing…(Advertising) /gateway offline — {reason}(Offline), else empty — its ownRoomStatusTextlabel so the UPnP line renders independently (G2's "room code wins" read as priority-of-attention, both lines coexist; room line is simply the code's only home). Lookup status:resolving…/joining room…/no such room/match full/gateway full — retry later/gateway offline — check connection or join by IP(Timeout + GatewayUnreachable share the offline copy);Found/Idleyield "" so the existing session line takes over — code mode feeds the Connecting→…FSM through G2'sjoin_roomglue, IP mode untouched. Invalid-code echo{text}▌ want 5 room-code chars. Escfrom Join stays Closed +net_stop(spec's "Esc walks back" satisfied by the in-panel Code⇄IP toggle button): the frozen N5 contractsonline_flow_back_walks_every_stage_to_closedandjoining_prefills_the_last_address_types_and_submitspin the stage walk; changing it would have broken them.NetProfile.gateway_enabled: additive#[serde(default = …)]true + round-trip tests. Runtime arming: production#[cfg(not(test))] gateway_compose_system(precedent:startup_goto_title_system) setsenabled = profile.gateway_enabled && parse_gateway_env(TETRIS_GATEWAY).is_some()— G2's opt-in env deviation intact (unset ⇒ disabled; no headless test can leak*Rto the internet), tests arm viaNetGateway::test_with_endpoint. Code path never touchesNetProfile.last_join_addr. Entering Join clearsCodeEntry+ resets guest lookup + recomputes the mode.- PRE-EXISTING latent test-harness bug found & fixed (surfaced by G3,
reproduced at HEAD
49248ac):online_app()mountsInputPluginbut never initializedSettings, so the fixed-stepgameplay_input_system(Res<Settings>) panicked withParameter …::settings failed validation: Resource does not existthe moment any test polled across real seconds (fixed timestep finally runs). Invisible until now because no earlier test spun the tree with wall-clock sleeps. Fixed by mirroring productionmain.rs(and the N6 harnesspeer_app):init_resource::<Settings>()in the helper; production untouched. - Real-relay fixture extracted from gateway.rs tests into
#[cfg(test)] mod testutil(spawn_real_gateway,raw_socket,recv_frame,reg) so online_ui flow tests reuse it; gateway tests unchanged (27 green). - Picking: no wiring per G2's note — all widgets built through the menu
helpers; Host room label + Join toggle carry
Pickable::IGNOREper the d2fc708 convention; hidden-stage behavior ridessync_hidden_ui_unpickable. screens_menu NOT touched (no stage transition forced it; 30 picking/screen tests green untouched). - Gates: online_ui 57 green (35 pre-existing + 22 new);
cargo test --workspacegreen (328 app + 28 gateway + 136 core + 6, 1 doc-ignored); clippy--all-targets -D warningsclean (twotoo_many_argumentsallows on the entry-input/label-sync systems, established convention per screens_menu.rs:520);cargo fmt --allclean. NOTE for G5: flow tests bind real loopback UDP — keep them off the--test-threads-tight runs (N6-documented contention).
- TDD: 22 pure/unit tests written first against the not-yet-existing API
(RED = 96 compile errors:
- files edited/created:
crates/tetris-app/src/core_bridge/net/online_ui.rs(pure layer, mode toggle, Host room line, entry widget, mode-aware input/click/label-sync systems,gateway_compose_system, +22 tests,Settingsinit inonline_app())crates/tetris-app/src/settings_persist.rs(gateway_enabled+ 2 tests)crates/tetris-app/src/core_bridge/net/gateway.rs(test-relay fixture →pub(crate) mod testutil; production code untouched)gateway-plan.md(this log)
- depends_on: [G1]
- location:
.github/workflows/release.yml,crates/netplay-gateway/{Dockerfile,blockfall-gateway.service,README.md},scripts/gateway-smoke.sh(new),README.md(root: one pointer line) - description: release.yml builds a static musl gateway binary
(x86_64-linux-musl via cargo target + musl-static linker — if the runner
setup fights, fall back to plain
--release+ note) and attaches it to GitHub Releases alongside the game assets (blockfall-gateway-<tag>- x86_64-unknown-linux-musl). Dockerfile: scratch FROM + the musl binary, EXPOSE 27016-27999/udp, ENTRYPOINT, documented env vars. systemd unit (hardened: NoNewPrivileges, ProtectSystem=strict, DynamicUser=yes, capabilities-only).gateway-smoke.sh: build + run +*R/*G/*Floopback self-test using two tiny nc/python clients OR acargo run -p netplay-gateway -- --self-testflag (preferred, zero deps). Root README: "Running a gateway" section pointer (ports, firewall range). - acceptance:
cargo build -p netplay-gateway --releaseclean; self-test passes; Dockerfile builds locally if docker is present (else CI-verified only — record); unit/systemd files are static-checked (systemd-analyze verifyif present); release.yml YAML validated (python yaml.safe_load — a colon in a step name is a known footgun); workflow change must not alter the existing game-asset job behavior. - status: Completed (2026-09-29, shipped as
7c34186— run alongside G2; the status row here drifted and is corrected by G5) - log:
- Delivered per plan: musl static gateway build job in
.github/workflows/release.yml(release assetblockfall-gateway-*), scratchDockerfile(+.dockerignore), hardenedblockfall-gateway.service,scripts/gateway-smoke.sh(uses the binary's--self-test), ops README, and the root README "Running a netplay gateway" pointer.
- Delivered per plan: musl static gateway build job in
- files edited/created: (see commit
7c34186: release.yml, crates/netplay-gateway/{Dockerfile,Dockerfile.dockerignore,blockfall-gateway.service,README.md}, scripts/gateway-smoke.sh, README.md)
- depends_on: [G3, G4]
- location:
crates/netplay-app…NO —crates/tetris-app/src/ core_bridge/net/harness.rs(E2E extension),gateway-plan.md(logs),netplay-plan.md(addendum pointer),README.md"Playing online" rewrite (Room codes become the primary cross-WAN path; UPnP and manual forward become the direct/advanced paths),CHANGELOG.md(Unreleased) - description: The crown test: two in-process game Apps (bot-vs-bot,
reusing the N6/N7 E2E machinery) connect through a real in-process
gateway over loopback UDP (real control handshake, real virtual-port
data plane, real netcode), full Garbage match to a crowned winner with
per-60-tick hash-stream equality. Plus: gateway-unreachable path
(no crash, clear status); busy path (
*Bsurfaces before the 10 s JoinTimeout);--ignoredtag only if the suite can't stay cheap (target: rides normal CI like the N6 E2Es). Docs rewrite as listed. - acceptance: E2E green in
cargo test --workspace; docs review; all gates green. - status: Completed (2026-09-30) — the gateway ships with G5; all of G1–G5 are shipped (see Final Status below).
- log:
- Crown test:
e2e_bot_vs_bot_garbage_match_through_gateway_relay(harness.rs) — two production-stackMinimalPluginsapps (bot-vs-bot Garbage) joined through a real in-processnetplay_gateway::room::Gateway(G3'stestutil::spawn_real_gatewayfixture, realRealAllocdata sockets on port-0-allocated loopback): host registers via the real G2 driver (Listeningedge →*R→*A, random room code), guest runs the real join-by-code path (join_room→*G→*F→ driver handsgateway_ip:vportto the productionnet_join), full match to a crowned winner with the per-60-tickSnapshotHashstreams compared on their whole common prefix (≥ 3 boundaries, plus equal final snapshots and a no-Desyncscan), and teardown proven end-to-end:net_stop→*D→ fresh*Ganswers*Eseconds after the match (the room demonstrably released, not GC-coasted). ~1.2 s, rides normalcargo test --workspace— no#[ignore]. - Per-IP leg distinction (G1's constraint): both apps share one
process, and every
0.0.0.0-bound socket sources127.0.0.1, which would blind the relay's per-IP attribution. The host legs therefore pin127.0.0.2via two small test seams (recorded prominently, G2 precedent):session::net_host_on(world, bind, port)(anet_hostbind-address parameterization — productionnet_hostunchanged, still0.0.0.0) andNetGateway::with_leg_bind(ip)(the control socket's bind,Nonein production). Guest legs stay fully production-shaped (0.0.0.0→127.0.0.1). Nothing bypasses the relay: the client transport accepts packets only from the exact*Fendpoint (127.0.0.1:vport), and the host's socket on127.0.0.2speaks only to it — a relayed connection is the only possible path. - Crown-test catch (production bug, fixed here): the G2 control system
sent
*Don every edge offListening— including the routineListening → Handshakingpeer-connect edge — closing the room's virtual data port mid-handshake (and again mid-match on the Ready edge). Red evidence: with the fix reverted the crown test deadlocks the relay and reportshost Lost(Transport), guest Lost(Timeout). Fix: the peer-connect edge now hands the room over (announcement clears, code kept) while the netcode session itself keeps it alive (room.rstreats host data as keepalive — that GC design was always the intent);*Dmoved to the true stop edge (any hosting phase →Idle), which matches the spec's "on net_stop/teardown sends*D" more faithfully than the old code (anet_stopfromInMatchpreviously sent nothing). Neither G2's nor G3's tests could see this — they never connected a real pair through the relay. - Degradation paths (same fixture family, all riding normal CI):
gateway_down_surfaces_offline_line_and_stays_retryable: closed endpoint →LOOKUP_TIMEOUTsurfacesGuestLookupState::Timeoutmapping to the shippedgateway offline — check connection or join by IPline (asserted inside the JoinTimeout window), session never reaches netcode, no crash, and a follow-upjoin_roomtakes the sticky terminal state back in flight (retryable).gateway_busy_room_surfaces_match_full_before_join_timeout: registered + guest-pinned room (raw legs per G1) → client*B→GuestLookupState::Busy→match full, observed in ≪ 5 s (vs the 10 s JoinTimeout it replaces), session staysIdle, noJoinTimeoutevent. - Flake note: one run of
state::smoke_tests:: app_with_all_plugin_stubs_runs_frames_without_a_windowfailed once among 10+ fullcargo test --workspaceruns and 5 full-binary runs after the change, never reproduced (isolated, 4 targeted-concurrency stress runs, oversubscribed runs); that test exercises no gateway code (driver disabled-by-default zero cost, neither seam used) — consistent with the N6-documented systemic parallel-flake family (board_730571a0). - Gates:
cargo test --workspacegreen twice-plus — 331 app (328 + 3 new) + 28 gateway + 1 gateway integration + 136 core + 6 + 0 doc-tests, 1 soak ignored as before; repeated full runs (incl. 2× oversubscribed) green;cargo clippy --all-targets -- -D warningsclean;cargo fmt --all --checkclean. - Docs wrap: README "Playing online" rewritten (room codes primary,
UPnP/direct-IP/NAT as advanced paths,
TETRIS_GATEWAYdocumented with its default + empty-disables semantics, short-sessions warning kept); "Running a netplay gateway" gained theTETRIS_GATEWAY+--self-testpointer; CHANGELOG Unreleased gained the feature block; netplay-plan WAN addendum gained a one-line shipped pointer.
- Crown test:
- files edited/created:
crates/tetris-app/src/core_bridge/net/harness.rs(+3 tests, ~400 lines)crates/tetris-app/src/core_bridge/net/gateway.rs(leg_bindtest seam,with_leg_bind, peer-connect takeover fix + stop-edge*D)crates/tetris-app/src/core_bridge/net/session.rs(net_host_onbind seam — 10-line refactor ofnet_host, production behavior identical)gateway-plan.md(this log + G4 status correction + wave table + Final Status),netplay-plan.md(one-line addendum pointer),README.md("Playing online" rewrite, Features line, gateway section pointer),CHANGELOG.md(Unreleased gateway feature block)
| Wave | Tasks | Can Start When |
|---|---|---|
| 1 | G1 | Immediately |
| 2 | G2 ∥ G4 (as run — G4 shipped as 7c34186 alongside G2, not after G3; the original plan's wave-3 pairing drifted) |
G1 |
| 3 | G3 | G2 |
| 4 | G5 | G3, G4 |
- Wire: round-trip + rejection + the 0x2A-demux netcode-byte-range pin (G1).
- State machine: pure virtual-time table tests incl. GC and rate limits (G1).
- Client: fake-gateway integration over loopback + headless-App flows (G2), UI flow tests per transition (G3).
- Crown: full game E2E through the relay with hash-stream equality (G5).
- Everything rides
cargo test --workspace— no new CI jobs, no network.
- 0x2A demux assumption: netcode type bytes are 0..5 + 0xFF — pinned by a test reading the renet source constant list (net/mod.rs notes style) and by the E2E (real netcode through the relay would break loudly).
- Virtual-port socket management: FDs = rooms; 983 slots ≪ ulimit; GC
closes; unit tests cover slot exhaustion →
*Fskipped → busy reply with a distinct code (documented). - Guest port instability: renet client keeps one socket for the connection lifetime (netcode requirement) — pin-on-first-data is safe; a mid-match NAT rebind = existing Lost path.
- Two guests race for one room: first pins, second gets
*Bif after pairing, else the existing max_clients=1 netcode outcome. - Gateway down: everything degrades to today's behavior (IP join +
UPnP + LAN); explicit
gateway offlineUI line; never blocks play. - release.yml musl toolchain flakiness: fallback = plain-linux release binary (glibc ≥ runner's is fine for our box) — decide at G4 time, log it.
- Open relay abuse: unauthenticated control frames let anyone register/lookup rooms (same posture as Unsecure netcode auth v1); rate limits cap scan/DoS noise; codes are the secret; payloads stay encrypted. Documented, accepted for v1 like the game's auth stance.
All G1–G5 shipped (2026-09-30). Room codes are the primary cross-WAN
path (Room ABCDE — share with a friend), the relay rides its own
zero-dependency crate (crates/netplay-gateway) with packaging and CI, the
client surfaces every failure mode in words, and the whole stack is pinned by
an E2E that crowns a full Garbage match through the real relay with per-60-tick
hash-stream equality — all riding cargo test --workspace.
Symptom (field report). Two players over the relay: guest enters the
room code, sees the pair succeed, then dead air — the guest sits in
Connecting until the netcode join times out while the host sits in
Listening seeing nothing. Reproduced against a consumer NAT: the guest's
netcode connect requests reach the relay, the relay forwards them to the
host's advertised game_port from the *R, but on the host side that
advertised port never had an inbound UDP mapping — the host's game socket
was created by the netcode server and never sent a single packet, so the
host router dropped every relayed packet. The relay's pin-on-first-data rule
(G1) made this invisible in the loopback suite: on the test box the
advertised port is reachable, so relay_loopback and the crown E2E stayed
green while the WAN path was broken.
Design. The fix is a classic host-side UDP punch, driven by a new gateway→host control frame so the host can punch the exact socket the session will use:
*Vframe —*V <code:5B> <vport:u16>(9 bytes), sent by the relay immediately after every*A(initial and keepalive).*Astays 7 bytes; everything else on the wire is frozen. The vport is the room's virtual data port — the only source address whose relayed packets will reach the guest, so punching toward it opens the host NAT mapping for the relay's forwarded traffic.- Deferred handover (
session.rs) — with the gateway armed,net_hostbinds the game socket and holds it (PendingHostSocket) while still goingListeningimmediately (the port is reserved;*V/handover add ≤ 3 s of latency). The gateway driver (gateway.rs) punches once (0x2Bdatagram, contents irrelevant — NAT side effect only) from the held socket towardgateway_ip:vport, then hands the socket to netcode (handover_pending_host_socketbuildsNetcodeServerTransport+RenetServeron the same bound port). Guest connect requests that arrived during the announce window sit in the socket's OS receive buffer and are picked up on the transport's first poll — pinned bybuffered_connect_requests_survive_deferred_handover. - Relay-side address book (
room.rs) — the room trackshost_data_addr(init: advertised(host_ip, game_port)from*R). Any data packet whose source IP matches the host getshost_data_addr = src: punch, refresh, NAT-port-rotation tracking. Attribution is IP-level because the host has two sockets (control + game) whose ports differ; guest pinning is unchanged (first data from a non-host IP) and guest→host forwarding always targetshost_data_addr. A keepalive*Rrefresheshost_data_addronly until the first punch — keepalives arrive from the control socket and must not clobber the punched address (test-pinned). - Degraded-path honesty — a room whose
*Fsucceeded but whose netcode connect then times out showshost unreachable — ask host to enable UPnP or port-forward UDP 27015on the Join status line (room_connect_failure_text, selected whileGuestLookupState::Foundis sticky).*B/*E/gateway-offline and every IP-mode line are untouched.
Timing. First *R + 3 s without *A → hand over punch-less (DNS dead,
gateway down — LAN/direct must never wait on the gateway). *A + 3 s
without *V → hand over punch-less (old gateway, lost frame). *V at any
time while Announced → punch + hand over immediately. NetStatus stays
Listening throughout; no new session states.
Compatibility matrix (all pinned by tests).
| game | gateway | behavior |
|---|---|---|
| new | new | *V punches, guest connects immediately |
| new | old (no *V) |
3 s after *A (or *R) the socket is handed over punch-less — same outcome as pre-fix: LAN/direct works, punching a strict NAT does not; the actionable copy tells the host why |
| old | new | strict wire decode rejects the unknown *V type byte → old clients silently drop it (pinned by pre_fix_decoder_drops_v_frame_like_any_unknown_type + an equivalent client-level pin) |
UPnP on the hosting path is unaffected (it triggers on the Listening edge,
which now arrives at bind time, earlier than before). One documented
aging caveat: if the punch's NAT mapping is aged out (some routers < 30 s)
and no guest connects in that window, the keepalive *R cannot re-punch
(it refreshes only until the first punch) — acceptable for human-paced
joins; noted in room.rs module docs.
Operator deploy. The gateway is on the pull cycle: tag → CI builds the
musl binary (the release job runs --self-test, which now also pins the
*A+*V handshake) → on threadripper systemctl start blockfall-update.service (or wait for the 6 h timer) adopts it —
--self-test is the install gate; failures keep the previous container.
Watch it with podman logs -f blockfall-gateway (banner + collision +
per-60 s slot summaries) and journalctl -u blockfall-update.service -f
(adopt decision). Old clients are unaffected by the new binary; games that
have not updated still work against it (drop-unknown rule).
Symptom (2026-09-30, first two-machine room-code test). Connect and
match start worked (host punch confirmed — the join completed in ~1 s), but
only the host's controls did anything. Deterministic in both directions of
the same failure: the host executed the empty-input path for every tick
where the guest's TickInput had not arrived.
Diagnosis. The guest sent everything (259 queued at its lockstep
rings); the host received everything and dropped all of it as late
(dropped_late_inputs = 258, logged only at debug!). D = 8 ticks
(≈133 ms) must cover guest-mirror lag (one host→guest hop) plus the
guest→host hop; the gateway's 100 ms poll alone made that impossible off
loopback. The loopback-only tests structurally could not see it — and the
relay E2E asserted only hash-stream equality, which an inert guest still
satisfies (the host's board is authoritative and the mirror follows).
Fix (three parts + a test-honesty part).
netplay-gateway:POLL100 ms → 10 ms. Worst added latency per hop drops 10×; the drain loop over nonblocking sockets is negligible.tetris-app: RTT-adaptive input delay. The host callsdelay_ticks_for_rtt(server.rtt(peer))atstart_net_match— one measured round trip + 4 ticks of jitter, clamped to the existing 2..=30 window — raisesNetSession::input_delay, and the guest adopts it via the existingMatchStart.match_delaychannel (no wire change). Netcode RTT is game-socket-to-game-socket, so relay hops are inside the measurement. Zero/absent sample never lowers the negotiated delay.- Visibility: late drops now
warn!on the first five and then once perHASH_CHECK_PERIOD(rate-limited), andNetLockstepgainedapplied_remote_actionsalongsidedropped_late_inputs. - Test honesty: the gateway-relay E2E now asserts `applied_remote_actions
0
**and**dropped_late_inputs == 0` — a hash-equal inert-guest pass is impossible. It also runs at 4× virtual speed (the old 12× turned the relay's real-time 10 ms poll into ~12 sim ticks per hop and burned the whole delay budget even on healthy code — that is exactly what it caught on first run after the assertions landed: 258/259 late again at 12× before the speed/D pairing was corrected).
Production math after the fix. LAN-through-relay: RTT ≈ 25 ms →
floor 6 → D stays 8 (133 ms) vs ~2×(5–10 ms) path — ample. Cross-WAN
RTT 150–250 ms → D ≈ 13–19 (217–317 ms) — inputs land, latency is
absorbed by design. Hosts can still force a value with TETRIS_NET_DELAY
(host side is enough; it propagates through MatchStart).
Operator note. Deploying the 10 ms-poll gateway requires the CI release
cycle as before (tag → musl asset gated by --self-test → pull agent).
Old games work against the new gateway unchanged; new games also work
against an old gateway, but a new-game-vs-old-gateway pair keeps the old
100 ms latency — the adaptive delay covers it as long as the path RTT +
2×100 ms stays under the raised D (it does: RTT includes the hops).