Files
skate-3-rust-engine/docs/steam-p2p-plan.md
T

406 lines
81 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> Current implementation: ten-player transport-neutral host/guest sessions with v2 compression and scheduling. See [multiplayer-test.md](multiplayer-test.md). Earlier planning/baseline notes below are retained for context.
# Steam P2P multiplayer plan
> **Superseded design:** this document records the original planning pass. The user's subsequent implementation request requires collisions and a transport-neutral game. See [the implemented two-player test](multiplayer-test.md) for current behavior. Its first collision implementation uses locally owned physics with dynamic remote proxies, not the no-collision recommendation below. Steam is an optional out-of-process byte relay, and local testing works without Steam.
Research date: 8 September 2026. Inspected engine baseline: `da364c9` (Bevy **0.18.1**, with local `bevy_pbr` and `bevy_core_pipeline` patches). **Design only; no multiplayer implementation is authorized by this document.**
## Recommendation
Build **casual free skate for 2–8 players**, defaulting to four, using Steam lobbies for discovery/membership and **Steam Networking Sockets** for authenticated P2P transport. One player hosts the session and forwards accepted state to the other players. Use Spacewar/AppID **480 only for development**. Keep the current direct XInput path; do not initialize Steam Input, install action manifests, or change Steam/controller settings from the application.
Initially, **each player's machine owns that player's skating physics**. The host owns admission, player identities, map changes, spawn/reset permissions, shared settings, and routing. Remote players are presentation actors without collision bodies. Replicate completed physical state and a bounded animation description, not all skin bones, raw memory, or old recomp hooks. This preserves local flick/trick timing and avoids running every player's full native-derived simulation on the host. It deliberately provides **no competitive score authority or cross-player physical collisions**. If either is a requirement for the first release, choose the more expensive host-authoritative alternative in §4 before implementing.
Use `steamworks = "=0.13.1"` behind a small owned Bevy adapter. Keep a single Sockets connection per guest to the host and one transport lane initially. The published binding has a channel/lane mismatch that makes blindly configuring multiple lanes unsafe as a design assumption (§3). Start with absolute actor snapshots at 30 Hz, the existing approximately 60 Hz simulation, and roughly 100 ms of remote interpolation. These are **initial budgets to verify**, not measured performance or promises about internet latency.
## 1. Evidence and scope
Labels used below:
- **O — observed:** inspected current code or historical setup/input source.
- **R — researched:** a capability documented by its upstream maintainer.
- **P — proposed:** an implementation decision or initial budget, not existing behavior.
- **U — unresolved:** needs a later source/data experiment or user-run validation.
All unlabelled design instructions after §3 are **P**. No network library was installed; no builds, game sessions, Steam launches, controller changes, asset conversions, or runtime checks were performed. The historical project was inspected only for bootstrap/input evidence. Its replication, memory hooks, networking architecture, and packet formats are not dependencies or templates for this plan.
### Current engine observations
Paths below are relative to the repository; links point to the inspected modules. Other tasks are changing maps, rendering, map switching, plants, and character configuration. Their final interfaces must be reconciled with this baseline before implementation.
| ID | O: current code fact | Design implication |
|---|---|---|
| O1 | [app.rs](../crates/skate-game/src/app.rs) chains `SimulationSet::{Input, Controls, Physics}` in `FixedUpdate`; `GamePhysics`, `SkaterRuntime`, `PlayerControls`, and `PublishedTickInput` are single resources. | Do not add generic ECS transform replication and assume it represents simulation ownership. Initial remote actors need separate presentation storage; they do not need another full `GamePhysics`. |
| O2 | [physics/clock.rs](../crates/skate-game/src/physics/clock.rs) normally schedules 16,666,600 ns ticks. [physics/frame.rs](../crates/skate-game/src/physics/frame.rs) consumes camera rate requests; [physics.rs](../crates/skate-game/src/physics.rs) updates `Time<Fixed>` from that clock. The physical step remains the native approximately 1/60 s value. | Online mode must use one stable cadence policy. A player's camera slow-motion request must not slow the network/session clock. |
| O3 | [input/platform.rs](../crates/skate-game/src/input/platform.rs) calls Windows `XInputGetState`/`XInputGetCapabilities` directly. [controllers.rs](../crates/skate-game/src/input/controllers.rs) polls four slots, preserves raw conversion/history, and chooses the first ready device. [TickInput](../crates/skate-core/src/input/tick.rs) publishes immutable 18-action snapshots. Non-Windows polling currently reports unsupported. | Preserve conversion and flick sampling. Do not substitute Bevy/gilrs or Steam action values. XInput alone is not native support for every PlayStation/Switch device. |
| O4 | [physics/controls.rs](../crates/skate-game/src/physics/controls.rs) advances derived input and gesture history once per physics tick. Offboard movement is transformed using the local camera's horizontal direction. | Raw stick values alone are insufficient for future authoritative remote simulation; its input contract also needs a movement basis/world direction. |
| O5 | [physics/frame.rs](../crates/skate-game/src/physics/frame.rs) advances animation, consumes its physical packet, selects physical state, queries contacts, solves, publishes physical feedback, then updates camera/output. [solve.rs](../crates/skate-game/src/physics/solve.rs) solves board, skeleton, targets, joints, drives, and self-assembly contacts together. | Graph state and physics are coupled. Restoring root position/velocity cannot restore a skater. Keep local board/skater interaction intact. |
| O6 | [BoardWorld](../crates/skate-core/src/physics/board_world.rs) combines static triangles/indexes with mutable contact scratch buffers. The [board](../crates/skate-core/src/physics/board.rs) has seven bodies; the [skeleton](../crates/skate-core/src/physics/skeleton_body/mod.rs) has 26 physical parts, 24 animation parts, and four extra drive targets defined separately. | A future multi-simulation host should share immutable geometry/assets and allocate contact scratch per actor; cloning whole worlds wastes memory and sharing mutable query results is incorrect. |
| O7 | [SkaterAnimation](../crates/skate-game/src/skater_animation.rs) has action/motion controllers, persistent playback trees/channels, physical feedback, clip histories, stance and packet publication. [PoseCommand](../crates/skate-core/src/animation/playback_tree.rs) supports clip, pose, nested blends, weighted/channel blends, add and mirror. | A single clip name or physical-state enum will not reproduce flips, blends, gestures, stance, or board movement. Export the evaluated command description at its existing boundary. |
| O8 | [render_pose.rs](../crates/skate-game/src/physics/render_pose.rs) begins with evaluated animation, then overwrites mapped physical output and board locals. [SkeletonOutput](../crates/skate-core/src/physics/skeleton_output/mod.rs) consumes 24 physical volume poses; [board output](../crates/skate-core/src/physics/skeleton_output/board.rs) uses six board body transforms, two steering truck frames, wobble and compression. | This is a narrower remote-presentation contract than a whole skin palette. Preserve physical overlays during bails and plants; clip playback alone is insufficient. |
| O9 | [presentation.rs](../crates/skate-game/src/presentation.rs) stores two completed root/bone/camera snapshots and cuts history for teleports/cadence changes. [animation.rs](../crates/skate-game/src/animation.rs) binds one skin through a singleton `AnimationStatus`. | Retain local interpolation. Add per-actor bindings and timestamped remote buffers; never feed their interpolated transforms into physics or the local camera. |
| O10 | [graphics_menu.rs](../crates/skate-game/src/graphics_menu.rs) opens with Escape and pauses `Time<Virtual>`. [replay.rs](../crates/skate-game/src/replay.rs) records presentation only and gates all three simulation sets during replay. | Menu/replay behavior must be session-aware. The existing replay is not a rollback checkpoint system. |
| O11 | [map_library.rs](../crates/skate-game/src/map_library.rs) discovers local `.skate` files by path; switching spawns another process, then the menu exits this process. [SkateMap](../crates/skate-data/src/skate_map.rs) has a format version, name, one spawn/heading, collision, rails and extensions, but no network identity manifest. | Online map changes must integrate with the seamless-switch task and retain Steam/session ownership. Never use this process restart path online. |
| O12 | [skate_world.rs](../crates/skate-game/src/skate_world.rs) uses embedded `RWCM` collision where available and rejects unsupported door/object physics. [physics/grind.rs](../crates/skate-game/src/physics/grind.rs) retains candidate, primitive, family, contact/release and trajectory state. | Hash the actual collision/grind interpretation, not just map names. Do not advertise synchronized doors/NPCs/props that the runtime does not simulate. |
| O13 | [manifest.rs](../crates/skate-data/src/manifest.rs) loads one character scene and graph paths; [assets.rs](../crates/skate-game/src/assets.rs) has one character asset. [skater.rs](../crates/skate-game/src/physics/skater.rs) constructs the current custom skater with an empty pro selector and no physical hat. | Character-task APIs must distinguish local preferences, render attachments, rig compatibility and physics-affecting equipment. No pre-existing network character schema can be assumed. |
| O14 | The workspace has no Steam dependency or multiplayer plugin. Existing core tests establish particular math/behavior cases, not cross-machine whole-simulation determinism or restore/replay equivalence. | Lockstep/rollback is unproven. Do not infer determinism from Rust ownership, native arithmetic comments, fixed dt, or presentation replay. |
| O15 | [climbing/mod.rs](../crates/skate-game/src/physics/climbing/mod.rs) can finish a tick and publish a pose before the normal graph/solve path. [climbing/approach.rs](../crates/skate-game/src/physics/climbing/approach.rs) also modifies the render pose after ordinary physical publication. Authored reach/mantle clips live outside the stock banks. | Export after the entire `frame::advance` returns, including either path. A stock `PoseCommand` export alone misses existing climbing, not just future plant work. |
### Historical bootstrap/input evidence
**O:** The requested directory ending `preview/.18-Windows` does not exist. The actual distribution directory is `Skate3CustomEngineLayer-0.1.0-preview.18-Windows` under the user's `CLEAN` directory. Its `steam_appid.txt` contains `480`; it contains `steam_api64.dll` and `.cel-steam/bootstrap-steam.ps1`. The script obtains a hash-pinned Steamworks.NET standalone archive, extracts the x64 Steam DLL, and stages it locally. This is historical provenance, **not the new installation strategy or proof of redistribution entitlement**.
**O:** In the associated release source, `src/skate3_steam_backend.cpp`:
- `EnsureDevelopmentAppId` (around line 598) sets process `SteamAppId` and `SteamGameId` to 480 and creates the marker file.
- `PrepareSteamInputManifest` (around line 320) copies standard gamepad templates from Steam and writes `game_actions_480.vdf`.
- The comment around line 1015 explicitly identifies Spacewar's action layout as incompatible with direct XInput. Initialization around line 1138 installs that manifest and calls Steam Input initialization. Its fallback warning says Spacewar's per-game controller setting may still be required.
- `CHANGELOG.md` records the preview.8 standard-gamepad-manifest workaround and, around line 240, earlier initialization of Steam to avoid controller device reordering when multiplayer starts.
**Conclusion:** these files document two plausible, distinct historical causes and an adopted workaround. They do not establish which exact Steam profile/device/driver caused the user's particular failure. No current Steam settings, private account data, logs, or runtime controller behavior were inspected. The associated research workspace was searched narrowly for Steam-input lessons; no stronger causal evidence was found. Applicable instructions were read; no historical code was changed.
## 2. Steam bootstrap, controller policy and distribution
### Development bootstrap
**R:** Valve uses 480 for its Spacewar example. Direct development launches use a `steam_appid.txt`; Steam initialization needs a running signed-in Steam client, a valid app/license context, and matching OS user/elevation. The marker must be absent from uploaded Steam builds. See [Spacewar example](https://partner.steamgames.com/doc/sdk/api/example) and [API initialization](https://partner.steamgames.com/doc/sdk/api#initialization_and_shutdown).
**P:** Add an explicit `steam` build feature; keep ordinary offline builds independent of Steam. In the development Steam package, place only the compatible x64 redistributable beside the executable and the ignored marker containing `480` in its working directory. Launch that exact development executable directly with its working directory set to the executable directory. Do not invoke `steam://run/480` to launch this engine: it selects Steam's installed Spacewar application. Use `Client::init()` with the marker, validate `utils().app_id()`, and show the actual AppID/development label in Multiplayer diagnostics.
Initialize once, **before Bevy renderer creation and controller polling**, and keep the client alive when returning to solo. No Steam reinitialization during Host/Join/Leave. Initial failure leaves solo usable and records the initialization reason. A retry is a deliberate user action; after any late successful retry, discard stale controller history and rediscover devices. This reduces device-lifecycle surprises, without claiming to disable remapping.
**R:** `steamworks` 0.13.1's `init_app` writes environment variables; `init` does not set those variables. Its build script links a dynamic Steam DLL and supports `STEAM_SDK_LOCATION` or bundled libraries. See [client source](https://github.com/Noxime/steamworks-rs/blob/v0.13.1/src/lib.rs) and [build source](https://github.com/Noxime/steamworks-rs/blob/v0.13.1/steamworks-sys/build.rs).
**P:** Prefer the explicit marker plus `init`, avoiding runtime environment mutation after worker threads start. Package the matched DLL through a reviewed build/package step, not the previous project's download script. **Missing DLL is a Windows loader failure before Rust runs** in a normally linked Steam build; do not promise a menu error can catch it. Validate packaging and retain an offline build. A future optional-DLL/delay-load design is a separate decision, not a reason to copy a dynamic export loader now.
### No Steam Input adoption
**R:** Steam's Windows overlay can hook XInput, DirectInput, RawInput and Windows.Gaming.Input and expose an emulated Xbox controller. This is why avoiding `SteamInput_Init` does **not** prevent every remap or virtual controller. [Valve's gamepad-emulation explanation](https://partner.steamgames.com/doc/features/steam_controller/steam_input_gamepad_emulation_bestpractices).
**P — application behavior:**
- Keep `input/platform.rs` and its raw conversion/history semantics. Do not call `Client::input()`, Steam Input init/run-frame/actions/glyph APIs, or legacy SteamController APIs. Do not register or copy any action manifest/template. Network callbacks are independent of adopting Steam Input.
- Do not patch hooks, bypass loaded input DLLs, edit Steam VDF/registry files, add controller-hiding tools, or set undocumented Steam environment variables. Do not claim `Shutdown`, disabling an overlay setting, or switching input libraries is a supported universal remapping kill switch. Valve documents `ISteamInput::Shutdown` as ending use of that interface, not such a switch. [ISteamInput](https://partner.steamgames.com/doc/api/ISteamInput#Shutdown).
- On focus loss, overlay/menu entry and disconnect, publish neutral gameplay input and clear queued flicks; require release before resuming. Continue direct polling for device status/UI independently of gameplay publication. Preserve the existing four-slot behavior initially; report hotplug/reordering instead of silently claiming device identity is stable.
**P — external setup, performed by the user when needed:** for a Steam-associated development launch, set **Library → Spacewar → Properties → Controller → Override → Disable Steam Input**, then restart the engine. If launched as a non-Steam shortcut, its own entry may be the relevant profile; verify the associated application instead of assuming the Spacewar override governs every launch route. The UI route is documented by developers using Steam's native-controller support, for example [Chronigma's controller setup](https://store.steampowered.com/app/4051150/Chronigma/). Labels can vary with Steam client versions. The application should show these short instructions and the active AppID, not change the setting. If the Spacewar entry is unavailable, treat manual setup/Steam support or moving development onto an owned test AppID as an unresolved setup step; do not auto-install/launch Spacewar to reveal it.
**R/P:** With an owned production AppID, configure partner-side controller defaults so supported native devices are not opted into Steam Input; don't supply a native action manifest. Valve documents the controller-type opt-in controls on the Application → Steam Input page. Those are application defaults, not control over every user's override. [Partner configuration](https://partner.steamgames.com/doc/features/steam_controller/getting_started_for_devs). We cannot change AppID 480's partner settings.
**U:** Users already relying on Steam to turn a non-XInput controller into an Xbox pad may lose access when it is disabled. This baseline has no direct HID/SDL support to substitute. Preserve the existing supported devices now; if wider controller support is required, add a separately reviewed native backend later. External desktop/chord layouts or third-party virtual-device software may also affect input. Without adopting Steam Input, the app cannot conclusively identify every emulated XInput device; diagnostics must distinguish “XInput device available” from “Steam Input verified off.”
### Eventual distribution gates
**R:** Valve assigns each game its own AppID. Spacewar's successful initialization does not authorize indefinite production use of 480. Steam publishing entails partner onboarding, agreements, app configuration and review. [Example AppID documentation](https://partner.steamgames.com/doc/sdk/api/example), [onboarding](https://partner.steamgames.com/doc/gettingstarted/onboarding).
**P:** Before external release, obtain the project's own AppID and permitted distribution setup, configure depots/packages/licenses, networking and controller defaults, and test with licensed test accounts. SteamPipe launch options must point to this executable; remove the 480 marker and reject an unexpected AppID in production. For an owned Steam release, `RestartAppIfNecessary(owned_app_id)` can precede initialization; honor its exit result. Do not use that restart path for 480 development. [Valve's restart behavior](https://partner.steamgames.com/doc/sdk/api#SteamAPI_RestartAppIfNecessary), [SteamPipe setup](https://partner.steamgames.com/doc/sdk/uploading).
Ship only authorized redistributables, matching the selected binding/SDK, through release packaging. A Rust binding's open-source license is not a replacement for Valve's SDK/distribution terms. Keep SDK files, DLLs, credentials and private game content out of Git. Do not transmit maps, models, clips, textures or extracted assets between players. Owning the Rust engine does not itself establish rights to distribute Skate 3 assets or branding; the release's content/branding permissions remain an explicit project release gate.
**R:** The open-source GameNetworkingSockets library does not by itself provide Steam's authentication, signaling or SDR service. Non-Steam distribution/cross-store access needs Valve's applicable arrangements; don't promise Steam services by bundling that library. [Valve's scope explanation](https://github.com/ValveSoftware/GameNetworkingSockets#quick-api-overview).
## 3. Dependencies and transport selection
| Choice | R: checked capability/compatibility | P: decision |
|---|---|---|
| `steamworks` 0.13.1 | Current crates.io stable on research date; maintained Rust SDK binding. The 0.13 family uses SDK 1.64, MSRV 1.80. Networking Sockets, Messages, lobby and callback APIs are present. [Repository/version matrix](https://github.com/Noxime/steamworks-rs), [pinned manifest](https://github.com/Noxime/steamworks-rs/blob/v0.13.1/Cargo.toml). | Exact pin for the first integration; an owned adapter controls startup/failure and scheduling. No Bevy version coupling. Recheck release notes/security before implementation. |
| `bevy-steamworks` | Published 0.16.0 dependencies are Bevy app/ECS `^0.18`, Steamworks `^0.12.2`; 0.17.0 requires Bevy `^0.19` and Steamworks `^0.13.1`. Confirmed from crates.io version dependency metadata, not stale README examples. [0.16.0 manifest](https://docs.rs/crate/bevy-steamworks/0.16.0/source/Cargo.toml), [current manifest](https://github.com/HouraiTeahouse/bevy-steamworks/blob/main/Cargo.toml). | 0.16.0 is a viable older binding alternative. Do not add latest and pull a second Bevy major/minor into this engine. A thin owned plugin is sufficient. |
| Networking Sockets | Explicit listen/connect/accept/close handles, connection status and poll groups. [API](https://partner.steamgames.com/doc/api/ISteamNetworkingSockets). | Choose this: it matches host/guest lifecycle, failure reporting and backpressure. `CreateListenSocketP2P` / `ConnectP2P`, virtual port 0, SteamID identity. No public server port or dedicated-server SDK for the initial player host. |
| Networking Messages | Implicit peer sessions, reliable/unreliable messages; built on the same underlying networking machinery. [Valve header](https://github.com/ValveSoftware/GameNetworkingSockets/blob/master/include/steam/isteamnetworkingmessages.h). | Viable for ad hoc peers, but implicit reconnect/lifecycle gives us more application work and less useful explicit connection control. Not selected. |
| Old `ISteamNetworking` P2P | Deprecated by Valve. [Networking overview](https://partner.steamgames.com/doc/features/multiplayer/networking). | Do not use `SendP2PPacket`, old P2P sessions, or a library transport built on them. |
| Generic ECS replication/rollback framework | This engine's mutable graph/physics owners are resources and native-derived structures, not a collection of independent replicated Bevy transform components (O1–O8). | Do not add another reliability protocol over Steam or a general multiplayer framework initially. Framework adoption would not solve state restoration or physical animation. |
**R/P — authentication and routing:** use Steam identities and normal authenticated/encrypted Sockets P2P connections; initialize relay access early and surface certificate/relay readiness failures. SDR handles relaying without user port forwarding; optional direct ICE/NAT paths exist. Require relay routing initially by disabling ICE candidates with the supported `P2PTransportICEEnable = 0` setting at socket/connection creation, verifying the effective setting and route in later tests. Never enable unauthenticated or unencrypted modes, or silently fall back to raw UDP. This favors IP privacy at a possible latency cost. [SDR](https://partner.steamgames.com/doc/features/multiplayer/steamdatagramrelay), [configuration definitions](https://github.com/ValveSoftware/GameNetworkingSockets/blob/master/include/steam/steamnetworkingtypes.h).
Transport authentication establishes peer identity; it does not establish honest simulation or permission to join this session. Bind the authenticated SteamID to current lobby membership, the accepted session and one assigned player key. Don't trust a SteamID copied into payloads. No Web API key or application secret belongs in a client. If later ownership/VAC/DLC policy requires session tickets, implement issuance, validation callbacks, cancellation and session end as a separate feature; never send tickets as lobby metadata. [Steam authentication scopes](https://partner.steamgames.com/doc/features/auth).
**O/R — binding caveat:** pinned `steamworks` 0.13.1 documents `NetworkingMessage::set_channel()` as usable for Sockets lanes, but its implementation writes `m_nChannel`. Valve defines `m_nChannel` for Messages and **`m_idxLane` for Sockets**. The wrapper's `send_messages` forwards the message without translating that field. This is a source-level mismatch, not a tested runtime bug report. [Binding message type](https://github.com/Noxime/steamworks-rs/blob/v0.13.1/src/networking_types.rs), [binding send path](https://github.com/Noxime/steamworks-rs/blob/v0.13.1/src/networking_sockets.rs), [Valve message layout](https://github.com/ValveSoftware/GameNetworkingSockets/blob/master/include/steam/steamnetworkingtypes.h).
**P:** use safe `NetConnection::send_message` on default lane 0. Keep reliable traffic small and queue it by priority before submission. Unreliable state does not acquire TCP-style reliability just because control shares the connection. If later bulk traffic needs separate lanes, first fix/review the binding's actual lane accessor and test lane isolation; do not call `set_channel` expecting it to work. No raw FFI workaround is part of this initial plan.
## 4. Authority and skating behavior
| Design | Local feel / engine work | Trust, costs and suitability |
|---|---|---|
| **Owner-authoritative skaters + host session (selected)** | Local input advances the existing full simulation immediately; remotes consume completed state. No state rewind in normal play. | One full physics actor per machine; host forwards data. Owners can lie about tricks/motion, host can cheat/censor, no fair global score proof. Suits free skate with ghost collisions. |
| Host-authoritative skaters + client prediction | Guest predicts its own full actor; host runs one actor per player from inputs, returns restorable state and last processed input; guest restores/replays. | Stronger rules and a path to shared physical objects. Approximately N full actor simulations on host, more memory, correction CPU and latency-sensitive transitions. Significant prerequisite work in graph/physics serialization. |
| Input lockstep / whole-world rollback | Everyone simulates players from identical inputs; rollback needs complete state history. | No evidence of cross-machine determinism. Complex contact/graph/clock state and divergent query results risk persistent drift; lockstep input delay is poor for flick controls. Reject initially. |
| Full mesh owner-state P2P | Same local simulation; send directly to every other player. | Removes the guest→host→guest hop and spreads upload load, but requires N(N−1)/2 connections, direct admission/revocation enforcement and more failure cases. Revisit if measured star latency/upload costs dominate. |
### Initial ownership rules
- Host allocates `PlayerKey = {slot:u16, generation:u16}` and binds it to the authenticated SteamID. A lobby ID is discovery identity, not an actor ID. Bevy `Entity`, pointers, filenames, native handles and numeric memory offsets never cross the wire.
- A random 128-bit session ID plus authority epoch identifies a hosted run. Each map transaction increments a map epoch; each spawn/reset increments that player's spawn revision. Discard data from old epochs/revisions. The host's authority epoch never migrates in v1.
- Clients send only their own `ActorState`, animation definitions and actor-event proposals. Host validates ownership, bounds, revisions and rate, then routes to other guests; it doesn't echo a guest's own state into its local physics. Host's skater uses the same acceptance/export path in process.
- Host validation is structural and coarse plausibility checking, **not an authoritative collision/physics re-simulation**. Reject impossible IDs, non-finite values and gross bounds violations. Do not silently clamp a bad pose into “valid physics.” Repeated invalid output ends that peer's participation. Never label accepted owner scores/tricks as verified.
- Host controls map, compatible gameplay/animation content, difficulty and physics-affecting profile policy. Stage changes for map/spawn boundaries initially, instead of mutating them halfway through an air/grind state. Graphics, camera, name presentation and input device choice stay local.
- Keep skater↔own-board, self-body, wheel/world, skeleton/world and grind/plant contacts in the owner simulation. **Exclude all remote skaters, remote boards, ragdolls and their attachments from local physics, camera collision and grind/plant acquisition.** Players can skate through each other and occupy the same rail. Use presentation transparency/name cues if overlap is confusing. Do not push a local player with an interpolated remote collider.
- Shared movable props, competitive scoring, S.K.A.T.E. adjudication and physical player collisions are deferred. A later host-owned prop system would need input/impulse authority and a correction policy, not just prop transform replication.
### Prediction/reconciliation alternative, precisely scoped
For owner authority, normal local movement needs **neither prediction nor server reconciliation**: it is the authoritative local result. Host-controlled spawn/reset/map changes are explicit discontinuities, acknowledged and applied on a fixed-tick boundary. No “correction lerp” writes a remote transform into a live solver.
If host-authoritative physics is chosen later, first create a versioned `SkaterCheckpoint` that captures every mutable dependency of a completed tick: board/skeleton/target body state; contact/probe histories and pending results; graph controllers' active behaviors, instance state and clocks; playback tree/channel histories; input/gesture timers and previous actions; player selector/lifecycle state; ground/air/grind/plant/climb/possession/wipeout state; feedback, IK/correction/wobble/filter state; and any random source. Assets/topology are identified by immutable hashes, not copied into each checkpoint. Separate per-actor query scratch from immutable `BoardWorld` data. Separate camera presentation, while retaining the movement basis and any gameplay-relevant camera-derived input.
A candidate protocol would accept one tick-indexed input record per player at 60 Hz, simulate on the host with a small input buffer (start at two ticks), send 20–30 Hz authoritative actor output and acknowledged input sequence, and send full/acknowledged-delta checkpoints only after their measured size is known. Keep approximately 250 ms of client input/checkpoint history. On correction, restore the completed host tick and replay unacknowledged input with the same quantized values; smooth **only presentation error**, with immediate cuts for teleports/state-incompatible corrections. Never replay sounds/session events twice. Define a per-frame replay CPU cap and resync path for excessive backlog.
These values are proposed experiments, not proof that two ticks cover internet delay. A pure board/root state is **not a checkpoint**; bitwise float arithmetic also does not prove whole-game determinism. Test restore→replay versus uninterrupted execution, then compare separate processes/CPUs/build settings. If that prerequisite fails, don't ship partial reconciliation disguised as host authority. Even successful deterministic tests do not eliminate the need for authoritative state under packet loss or dynamic contacts.
## 5. Menu, discovery and session lifecycle
Escape gains **Multiplayer**. Its page has **Host**, **Browse**, and the current connection status. Hosting exposes a session name (≤64 UTF-8 bytes), Public/Friends/Invite-only, installed map, and player limit (default 4, initial hard limit 8). “Server” means a player-hosted session in this version; there is no dedicated server browser/service. Passwords, chat, voice and arbitrary internet addresses are outside the first milestone.
Browse displays name, host, map, admitted/maximum players, availability and compatibility. Refresh is asynchronous and cancellable. Start with nearby Steam lobby results and offer worldwide search. Lobby proximity is not measured in-game ping; show actual RTT only after a connection, or “unknown.” Filter by fixed project namespace, protocol major and joinable status. Spacewar is shared with unrelated projects, so filtering is essential but is not an authentication boundary.
Proposed lobby keys, all bounded strings: `project=sk8-rust-freeskate`, `protocol`, `build`, `session`, `authority_epoch`, `host`, `phase`, `name`, `map_id`, `map_hash`, `sim_hash`, `players`, `limit`. The authoritative copies live in the host's handshake/roster; metadata is only an advertisement. Never put passwords, auth tickets, paths or assets there. Use Steam's member limit as well as host admission reservations; lobby count includes members still loading. Apply `AddRequestLobbyListStringFilter` before `RequestLobbyList`; use `CreateLobby`, `SetLobbyData`, `SetLobbyJoinable`, `JoinLobby`, `LeaveLobby`, and the relevant call results/`LobbyChatUpdate`/`LobbyDataUpdate` callbacks. Register request results as results, not interchangeable ordinary callbacks.
**R:** Lobby creation/search/join are asynchronous; only the owner sets lobby-wide data, while members have their own metadata. Steam supports public/friends/private lobby discovery and filtering; membership changes arrive as callbacks. Keep the lobby alive during skating for invites/late join. [Lobby flow](https://partner.steamgames.com/doc/features/multiplayer/matchmaking), [matchmaking API](https://partner.steamgames.com/doc/api/ISteamMatchmaking).
```text
Solo → CreatingLobby → HostingReady → InSession
Solo → Browsing → JoiningLobby → Connecting → CheckingContent → Loading → InSession
InSession → PreparingMap → LoadingMap → ReadyBarrier → InSession (new map epoch)
Any pending state → Cancelling → Solo
InSession → Leaving / SessionEnded → Solo
```
1. **Host:** initialize listen socket, create lobby initially non-joinable, establish session descriptor/roster, publish metadata, then open admission. Failure closes partial resources and leaves no advertised unusable session.
2. **Join:** request lobby, revalidate its owner/host/session metadata, reserve capacity on the host, connect to the authenticated advertised host, exchange protocol/content descriptors, load locally, then await an admitted spawn. Joining a lobby alone never spawns an actor or grants gameplay permission.
3. **Invites:** send an invite only after the player clicks Invite. Handle `GameLobbyJoinRequested` through the same join path. If already in another session, show the concrete destination and require a user choice before leaving. Parse a bounded numeric `+connect_lobby` argument at startup; current `Config::from_env` rejects it. **R:** that callback/argument is Valve's lobby-invite flow. [Friends callbacks](https://partner.steamgames.com/doc/api/ISteamFriends#GameLobbyJoinRequested_t).
4. **Spacewar limitation:** invites while this engine is already running can reach its 480 context; an invite when it is closed may launch Valve's installed 480 executable instead. Do not promise cold-launch invites until the project's own AppID/launch configuration exists. Provide manual in-engine browse/join as the development fallback.
5. **Loading:** show the actual stage (“Checking map”, “Loading character”, “Waiting for host”), elapsed time and Cancel/Back. Each attempt has a local generation so a late callback cannot resurrect a cancelled lobby. If a cancelled join subsequently succeeds, immediately leave that returned lobby.
6. **Pause/replay:** online Escape/overlay do not pause `Time<Virtual>` or the host session. Neutralize only gameplay controls and show that skating continues. Disable entry into the existing simulation-pausing replay while connected for v1, with a clear explanation; a later read-only replay viewer can run alongside live simulation. Disconnect/focus loss does not freeze other players.
7. **Quit/leave:** stop admission first if host, send the reason, close connections, leave lobby, clear rich presence, destroy remote actor trees, reset local network state and resume solo using the current installed map. Keep the process Steam client alive until process shutdown.
## 6. Animation and replicated state
### Presentation contract
**P:** add a read-only export at the evaluated `PoseCommand` boundary in `SkaterAnimation::advance`, and a completed-tick export after the **entire `frame::advance` returns**, including its climbing early-return path and late approach overlay (O15). Keep records tagged with the **same completed owner tick** through an explicit `CompletedTick` value; do not mix the pre-increment physical output tick with the incremented `GamePhysics::ticks`. Do not rerun `evaluate_pose(update_history=true)` to obtain a second copy: it consumes/advances history. Retain the description from the existing evaluation.
Remote rendering reconstructs the animated pose from its **own compatible local clips**, applies transmitted physical publication data through a read-only presentation adapter, then interpolates final local transforms. It does not run action/motion gameplay controllers, choose new trick branches, integrate root motion, solve contacts, or emit gameplay events. Root motion in clip commands is used only as the existing evaluator expects for that pose; the network root determines world placement, so it cannot be applied twice.
Transmit a reliable `AnimationRecipe` when the resolved command topology changes. It describes validated stack operations referring to a session-negotiated animation catalog: Clip, Pose, Blend, WeightedBlend, ChannelBlend, Add and Mirror, including channel flags and mirror mode. Dynamic clip sample times, previous sample times, loop count, weights and phase are carried in each actor snapshot. Stance/board availability and semantic trick state are explicit. Clip IDs are catalog indices tied to the content digest, not hashes accepted without collision checking or untrusted asset paths.
For physical presentation, transmit the **24 completed physical volume poses** consumed by `SkeletonOutput`, plus the six board body poses, two steering truck frames, wobble tilt/squish and two wheel compressions consumed by its board publisher. These are a bounded physics publication, **not a full render-bone stream**. Additional solver bodies/targets are not needed to draw the remote actor. The animation evaluator derives unrepresented bones, clothing/attachments use local rig bindings, and final hierarchy composition remains local. During bails, the 24 poses reproduce the owned ragdoll result instead of trusting a remotely resimulated ragdoll. In normal skating they also preserve physical deviations from the authored clip.
**P — climbing exception:** add explicit stock/reach/attached-climb presentation modes. The climb presentation adapter samples the locally installed reach/mantle clips and applies the named contact/clearance/settling modifiers as pose operations, without the climbing movement controller. On transition, a reliable `PresentationTransition` carries a self-contained frozen entry description (recipe, clocks and physical publication), the ground-entry description if needed, climb clip IDs and anchor geometry. It is at most 4 KiB and does not carry a final skin palette. The host caches it for late join; relying on a receiver having seen the entry frame would fail. Each state carries the selected mode and bounded current phase/time, hand-contact weights, anchor/landing basis and carry-board modifiers. Both authored climb clips and their modifier schema enter the compatibility digest. Preserve the board's separate root throughout.
**U:** validate that this read-only export reproduces every existing output operation, particularly O15's captured entry/fallback pose and bones outside the 24-part mapping, as well as new plant modifiers. Confirm that a frozen entry description can reconstruct the needed input without a bone palette and that quantized volume poses remain sufficiently rigid/accurate. This is a required milestone-1 gate, not a claimed solved animation problem. If a postprocessor needs more parameters, extend the named contract explicitly. Do not silently drop it or fall back to sending every final bone. Limits on command/clip counts below must be checked against representative real graph outputs before freezing protocol v1.
### State/event ownership by feature
| Feature | Transmit | Derived locally / policy |
|---|---|---|
| Riding/air/tricks | Root and board state, physical-state ID, stance/fakie/mirror/board-present flags, trick ID/phase and presentation recipe/parameters. | Full local clip sampling, skin hierarchy, materials and cosmetic attachments. No remote physics input. |
| Grinds/slides | Enter/change/exit event; current family, map primitive key, direction, distance along primitive, phase/contact flags in snapshots. | Local rails/materials and sounds. Missing/stale primitive keys fail validation; remote rail queries cannot latch local players. |
| Bails/recovery | Wipeout/recover transition, state phase, 24 physical publication poses, board state; explicit reset/spawn revision. | Render the transmitted physical pose. Do not expect identical ragdolls from a seed or clip alone. |
| Offboard/board possession | Biped ground/air state, carrying/attached/free board mode, possession side/anchor mode; independent board world pose. | Local attachment lookup. Do not parent the board to the skater merely because they are near each other. |
| Plants/climbing | Mode/limb, enter/release/exit tick, accepted contact point/normal or map-object anchor, phase and blend; physical pose supplies the resulting body placement. | Plant task owns contact selection and dynamics. Presentation consumes its exported result. No client-supplied contacts become local physical constraints. |
| Spawn/teleport | Host-issued spawn ID/pose, player spawn revision and first state. | Flush that actor's interpolation and input history. Ordinary state packets cannot authorize arbitrary spawn-revision changes. |
| Character | Approved manifest revision, rig/presentation digest, bounded selections and appearance IDs. | Resolve assets locally. Camera/control preferences are never replicated. |
## 7. Proposed wire protocol
Use a small `skate-net` crate with explicit little-endian encoding/decoding and checked lengths. No Rust struct dumps, `usize`, platform enum discriminants, bincode of engine internals, paths or pointers. Existing `serde` can remain for local configuration; it is not permission to deserialize arbitrary runtime state.
### Envelope and common types
Every message has a **40-byte header**, in this order:
```text
magic[4] = "SK8N", protocol_major:u16, kind:u8, flags:u8,
session_id:[u8;16], authority_epoch:u32, map_epoch:u32,
message_seq:u32, payload_bytes:u16, reserved:u16 = 0
```
The authenticated connection determines the sender. `PlayerKey` in payloads is `{slot:u16,generation:u16}`. At first Hello, session/epochs must match the advertised candidate; the host confirms the authoritative values. Before admission, permit only the handshake message set. Sequences are per sender/message class; compare u32 values with modular half-range rules and prohibit ambiguous jumps. `owner_tick:u64` identifies a completed simulation tick; `sample_us:u64` is monotonic time mapped into the session timeline, not wall-clock UTC. Never trust timestamps to grant more simulation time or capacity.
Hashes are 32-byte SHA-256 values of versioned canonical manifests or exact files. Define catalog IDs/enum values in the protocol, with checked conversions into local types. A point uses signed i32 millimetres in map space. A quaternion uses a largest-component index byte plus three signed i16 components in the range ±1/√2; reconstruct a canonical unit quaternion, validate the squared sum before a tolerance-limited normalization. A root pose is 19 bytes. A relative pose is 13 bytes: three i16 coordinates at 1/1024 m plus that quaternion. Human physical parts are relative to the skater root; the physical board part and board bodies are relative to the independent board root; authored truck frames use their declared local frame. The negotiated rig manifest fixes this mapping. If a relative offset exceeds approximately 32 m or rigid decomposition fails, reject/export a diagnostic and use the explicit reset/error path; **never saturate a transform into a different pose**. Qualify these ranges before v1.
### Messages
`R` below means Steam reliable, ordered within the selected connection/lane; `U` means unreliable, application-sequenced, stale/duplicate data discarded. There is **no relative ordering guarantee between R and U**. Do not depend on a reliable definition arriving before an unreliable snapshot referencing it. [Valve Sockets delivery semantics](https://github.com/ValveSoftware/GameNetworkingSockets/blob/master/include/steam/isteamnetworkingsockets.h).
| Message / direction | Required payload and limits | Delivery / initial frequency |
|---|---|---|
| `Hello` guest→host | lobby ID:u64, build protocol/content versions, capability bits:u64, simulation digest, map descriptor digest, rig/animation digest, client nonce[16]. Maximum 512 B. | R once per connection; reject mismatch with typed reason before actor construction. |
| `Welcome` host→guest | echoed nonce, assigned PlayerKey, host SteamID:u64, session settings revision, rates, admitted roster ≤8, map descriptor, session time sample. Maximum 4 KiB. | R; guest acknowledges accepted descriptor revisions. |
| `Roster` host→guests | roster revision:u32; ≤8 entries of key, SteamID, spawn/appearance revision, loading/admitted status. | R on change; latest complete version retained for late join. |
| `ContentReady` guest→host | transaction:u32, validated digests, appearance/presentation revisions, ready/error enum. | R; no optimistic spawn on a local loading screen ending. |
| `Spawn` / `Reset` host→all | transaction:u32, PlayerKey, spawn revision:u32, catalog spawn ID:u32 or validated host placement, root/board poses, owner start tick, applied settings revision. ≤256 B. | R; owner replies `Applied` with revision/tick; snapshots under a new revision may be buffered briefly until this arrives. |
| `ActorState` owner→host→others | Complete bounded record described below. No cross-snapshot delta dependencies. | U at 30 Hz; host forwards promptly without another fixed-rate wait; no echo to owner. |
| `AnimationRecipe` owner→host→others | PlayerKey, recipe revision:u32, catalog digest, command count≤64; opcode:u8 and validated static operands/parameter slots; ≤16 clip slots, ≤32 dynamic weight slots. Maximum 4 KiB. | R on topology change; cache recent revisions; host retains current recipe for late join. |
| `PresentationTransition` owner→host→others | PlayerKey, spawn revision, transition revision, entry tick, frozen entry/ground-entry descriptions, catalog climb clip IDs and validated anchor geometry, as specified in §6. Maximum 4 KiB. | R on entering a presentation mode; cache for late join; apply only with matching recipe and modifier revisions. |
| `ActorEvent` owner→host→others | PlayerKey, spawn revision, event_seq:u32, owner_tick:u64, sample_us:u64, state revision:u32, kind:u8, bounded kind-specific body≤96 B. Kinds: trick begin/land, grind enter/change/exit, bail/recover, board-mode change, plant/climb enter/release/exit. | R, capped at 64/s per actor with a small burst allowance. Host validates/deduplicates then forwards; owner's local effect is not replayed on acknowledgement. |
| `AppearanceProposal` / `AppearanceCommit` | PlayerKey, revision:u32, canonical manifest≤4 KiB, digest. Host checks policy, broadcasts commit, peers acknowledge availability/fallback. | R on change, max one proposal/s; rig or physics changes only at a reset/map boundary initially. |
| `MapPrepare` / `MapReady` / `MapCommit` / `MapAbort` | transaction:u32, next epoch:u32, descriptor/digests, settings revision, readiness or typed error; commit includes new roster/spawn plan and host start timestamp. ≤4 KiB each. | R through the barrier in §8. |
| `Ping` / `Pong` | nonce:u32 and monotonic send/receive timestamps:u64. | U at 1 Hz; pong echoes nonce and timing fields; no extra reliable heartbeat stream. |
| `Leave` / `SessionEnded` / `Reject` | reason:u16, related transaction:u32, bounded sanitized detail≤160 UTF-8 bytes. | R best effort, then bounded cleanup; an acknowledgement is not required forever. |
| `NeedDefinition` | PlayerKey, desired appearance/recipe revision, reason. | R, at most once/s per missing revision; limited pending storage, no request amplification. |
**`ActorState` field order / bounded layout:**
1. PlayerKey; spawn revision:u32; owner tick:u64; sample_us:u64; pose_seq:u32; appearance revision:u32; recipe revision:u32; state revision:u32; last event_seq:u32.
2. physical state:u16, actor flags:u16, trick catalog ID:u16, trick phase:u16 normalized, board mode:u8, presence/menu flag:u8, gameplay settings revision:u32. Flags encode stance/fakie/mirror, board-present, grounded and wiping-out; no arbitrary native flag word.
3. Skater animation-to-world and board-root poses (2×19 B); skater and board linear/angular velocities (four triples of i16 at 1/256 m/s or rad/s); octahedral ground normal (2×i16), surface catalog ID:u16, grounded contact flags:u16. Bounds/ranges are profile-validated; velocities are presentation aids, not force commands.
4. Grind descriptor: active:u8, family:u8, direction:i8, reserved:u8, primitive_id:u32, distance_mm:i32. Plant/climb descriptor: mode:u8, limb:u8, phase:u16, object/primitive_id:u32, contact point(3×i32), oct normal(2×i16), blend:u16. Zero/inactive descriptors have defined canonical values.
5. Clip count:u8 followed by each slot's previous/current sample seconds:f32×2 and loop count:u16; weight count:u8 followed by normalized u16 weights; global phase:f32. Maximum 16 clips/32 weights. Time ranges and loop values are checked against the local catalog; stack arity and weight counts must match the recipe.
6. 24 physical-part relative poses, six board-body relative poses, two truck-frame relative poses; wobble tilt/squish:f32×2 and compression:f32×2.
7. presentation_mode:u8; modifier_bytes:u16; up to 256 bytes defined by the negotiated presentation schema. For reach/climb: transition revision:u32, phase:u8, carry mode:u8, reserved:u16, phase time:f32, contact/blend weights (4×u16), two hand anchors/landing point (3×3×i32), forward/up oct normals (4×i16), board attachment mode:u16. Unused bytes are absent, not arbitrary opaque input to the renderer. New plant fields require an explicit schema/capability definition.
The envelope plus fields 1–6 total **868 bytes** at maximum clip/weight counts; field 7 makes that **871 bytes plus at most 256 modifier bytes (1,127 B)**. Retain a **1,200-byte hard ceiling**. Typical 800 B is only a planning estimate. Keep each actor record a separate message. Don't pack eight full actors into one unreliable message that fragments into many datagrams. Verify the effective transport MTU; 1,200 B is our message budget, not a guarantee of the Steam/UDP wire size. Missing any required definition means retain the latest at most three pending snapshots for ≤250 ms, request the definition, then show stale/missing presentation instead of evaluating unknown commands. A recipe over budget is an explicit protocol/content qualification failure, never truncated animation.
### Input contract
**Production v1 sends no gameplay input stream.** Owners already simulate their physics; remotes consume the resulting pose/state. Sending 18 actions in addition to that output provides neither authoritative validation nor better clip reconstruction. Controller bytes, raw device indices and packet numbers remain private/local.
Specify a separate, disabled-by-default `input-audit-v1` capability for a **later** simulation comparison or host-authority prototype. `InputBatch` is U and contains PlayerKey, spawn revision, newest input tick:u64, count:u8 (1–4), then four-or-fewer records in ascending tick order. Each record is: tick_delta:u8; controller_available:u8; gameplay_enabled:u8; reserved:u8; **18×f32 actions** in `GameplayActions` order; camera horizontal movement basis `(forward_x,forward_z):f32×2`; settings revision:u32. Four records plus envelope/prefix are under 420 B. Proposed send rate is 60 Hz, including the newest record and up to three predecessors; duplicates are harmless. The current local `TickInput` counter is distinct from `GamePhysics::ticks`, so map it deliberately after pause/reset rather than assuming equality.
Keep f32 action precision initially because conversion/deadzones/gesture thresholds are gameplay-sensitive. Reject non-finite or invalid action ranges, normalize/validate the movement basis and enforce a bounded tick window. The audit receiver records/compares but does not mutate live simulation. A host-authority implementation would additionally define missing-input synthesis (never repeat edge actions), processed-input acknowledgements, prediction lead limits and checkpoint packets, and would need its own protocol version. This plan does not pretend those absent checkpoints are implemented.
### Events and loss recovery
Persistently visible state is repeated in `ActorState`; unreliable loss cannot leave a permanent grind, bail or carrying flag stuck. Events trigger one-shot sound/UI only after their referenced state is available, using `(PlayerKey,spawn_revision,event_seq)` deduplication. Late join starts at the baseline event sequence and never replays old landings. Skip stale cosmetic sounds after approximately 250 ms; apply persistent lifecycle changes by revision even if their sound is skipped. Lobby/session/map events are host-only; an owner's `ActorEvent` cannot evict players, change maps or award trusted competitive scores.
Use `UNRELIABLE_NO_DELAY` for live snapshots/pings after connection readiness and `RELIABLE_NO_NAGLE` for small control messages. Drop/coalesce old unsent state rather than retrying it; retry a failed reliable enqueue only within the bounded application queue. Single-lane reliable messages share ordered delivery, so chunk manifests into ≤1 KiB application chunks, cap each object at 4 KiB and bound their total rate. Prioritize leave/map/admission commands ahead of unsent cosmetic chunks. Already-enqueued reliable data cannot be magically reprioritized or cancelled. [Send flags](https://github.com/Noxime/steamworks-rs/blob/v0.13.1/src/networking_types.rs).
## 8. Maps, spawns, late join and customisation
**Map descriptor:** `{map_id, package_format_version, content_schema, package_sha256, collision_grind_sha256, spawn_manifest_sha256, gameplay_digest, animation_catalog_digest}`. `map_id` is a stable catalog identifier; a filename or display name is insufficient. Initially require exact package hash equality as well as gameplay/catalog compatibility. Also compute the canonical gameplay digest from interpreted collision (including `RWCM`), materials affecting physics, rail/primitive ordering, spawns, and enabled dynamic-feature policy. Primitive IDs are canonical ordered indices scoped to that digest and map epoch, not addresses or display-name hashes. Hash relevant stock graph/collection/physics data and authored animation replacements in the gameplay/animation manifests. Do not hash per-user installation paths or graphics preferences.
Cache file hashes after local import/installation with file identity/size/modification tracking, but revalidate changed files; don't hash huge maps on every network tick. No asset payloads, file paths, URLs to auto-download, or local absolute directories go in handshake messages. Missing map → report name/version/hash and ask the user to install matching content independently. Cosmetic render-only hash relaxation can be a later explicit protocol capability; do not assume different renderer packages are compatible today.
**Spawns:** baseline maps expose one spawn. The map/spawn task should export stable spawn IDs and host-approved placements, grounded against the current collision. For v1, derive a few safe offsets around the authored spawn only after checking support/clearance; if no alternative is safe, ghost collisions allow sharing the authored spawn. Host validates every chosen placement, serializes admission reservations, increments spawn revision, and sends a reliable spawn. Do not accept arbitrary guest position teleports as a new spawn.
**Late join:** reserve one slot; validate descriptors; load map/rig locally; send a baseline roster, current appearance and animation recipes, and fresh absolute state for admitted actors. Receiver acknowledges content and remains absent until its own spawn is applied. Buffer only latest peer state while loading. On admission the host publishes roster/spawn to existing peers. Cancel/failure releases the reservation and transient resources. No input history replay or full match replay is needed in owner-authority mode.
**Map switching transaction:**
1. Only the host requests a map change. Close admission and send `MapPrepare(transaction,next_epoch,descriptor)`; keep old map/session active while peers validate availability. Don't advertise the new map as joinable yet.
2. Peers asynchronously stage local map/character/collision/grind data and acknowledge readiness. The map task must expose staging/commit/error/cancel operations; networking cannot invoke the old process-spawn switch. Continue callback/timeout pumping throughout.
3. Once every retained member is ready, host sends `MapCommit` with a future session timestamp (initial budget ≥500 ms), new epoch/settings and spawn plan. Each peer acknowledges receipt, swaps on its fixed boundary when due, clears old contact/graph/presentation/replay/input state and sends `ContentReady(applied)` for that epoch. A late commit is applied immediately but remains hidden/loading; never advance an old actor in new collision.
4. Host admits new-epoch spawns only after applied acknowledgements; repeated baseline/new absolute snapshots seed interpolation. Old packets are discarded by epoch. Release old GPU/assets through the renderer's normal lifecycle.
5. Before commit, a failure/deadline aborts back to the old map for everyone by default. After any peer has committed, do not roll others back based on reordered packets: finish the new map for successful peers, report/return failing peers to solo, and clean their reservations. Session joins remain closed until the host is ready. If staging cannot retain the old map within memory limits, the map task must expose a bounded reload-on-abort path; that cost must be visible, not called seamless without evidence.
**Customisation manifest:** version:u16, revision:u32, rig ID/digest, physics-profile ID/digest, animation catalog digest, stance/push preferences, posture/morph parameters with schema-defined limits, and ≤24 appearance slots. Each slot contains a known slot ID, catalog asset ID/version/digest and allowed color/material parameters. Maximum 4 KiB, no meshes/textures/paths/scripts. Distinguish cosmetic slot data from equipment that changes collision, body proportions, animation or physics. Host approves physics-affecting profiles; cosmetic-only missing items fall back to a known local default and display that fact. Missing required rig/physics/animation data rejects participation.
Use per-player root/skin bindings and independent appearance revisions. Shared immutable meshes/material sources may be cached; mutable attachment ownership is per actor. A remote outfit change must never mutate the local player's rig or preferences. First version applies rig/physics changes only at respawn; ordinary cosmetic attachment swaps can commit once the relevant local assets are loaded. The character task owns manifest resolution and lifecycle; networking owns validation, distribution and revision ordering.
## 9. Timing, interpolation and budgets
All values below are **P** and must be tuned from later tests.
- Simulate the owner at the existing approximately 60 Hz physical cadence. Online mode does not apply camera-requested global slow motion. Keep real network time separate from `Time<Virtual>` and simulation tick count.
- Send the latest completed actor tick at 30 Hz, with an immediate lifecycle event on transitions. Avoid sending all catch-up ticks after a long render stall. Host routes accepted updates promptly; the star must not add an unnecessary extra 33 ms coalescing delay.
- Estimate clock offset/RTT with monotonic ping/pong exchanges, using low-RTT samples and bounded gradual offset adjustment. Host sanity-checks guest timestamps against its arrival time/offset window and supplies a consistent timeline to other guests. Do not let a guest choose an arbitrary future interpolation time. Reset timing on reconnect; don't compare unrelated machines' `Instant` values directly.
- Render remotes approximately **100 ms behind estimated session time**, initially adaptable within 67–150 ms using observed jitter. This delay is additional to guest→host→guest transport latency, not a total latency claim. Keep a 32-snapshot/actor ring. Use translation lerp or bounded velocity-assisted interpolation and shortest-arc quaternion slerp; start with lerp around contacts to avoid spline overshoot through rails/walls.
- Reconstruct each endpoint's pose from its recipe/physical data, then blend final local poses with the same root interval. Never interpolate discrete physical states, catalog IDs or topology by their numeric values. Crossfade compatible recipe transitions, cut on spawn/map/rig changes, and suppress root-motion reintegration.
- If the render cursor runs beyond the newest state, allow at most **50 ms** of bounded root extrapolation in ordinary motion; freeze contact poses and bails rather than inventing a rail landing. After **250 ms without state**, display that actor as stale and fade it; an authenticated heartbeat alone does not make frozen actor data fresh. Resume from a fresh baseline, avoiding a long sweep across a teleport.
- Local presentation keeps the existing one-fixed-interval interpolation. The remote buffer does not add 100 ms to local controls/camera.
### Bandwidth and CPU examples
Assume one actor packet averages **800 bytes including our header** at **30 Hz**, with all players relevant to all others. This is **24 kB/s per actor stream**, decimal units. It excludes Steam/IP headers, retransmissions, events and occasional manifests. Reserve roughly 25% additional wire headroom as an estimate; measure actual transport statistics before setting advertised capacity. The maximum 1,200-byte profile increases these figures by 50%.
For N total players including host: each guest uploads one stream; host receives N−1 streams, and sends N−1 other actors to each of N−1 guests. Thus host upload is **24×(N−1)² kB/s**, host download is **24×(N−1)**, and each guest downloads **24×(N−1)**.
| Total players | Each guest upload | Each guest download | Host upload | Host download | Host upload + estimated 25% |
|---:|---:|---:|---:|---:|---:|
| 2 | 24 kB/s | 24 kB/s | 24 kB/s (0.192 Mbps) | 24 kB/s | 0.24 Mbps |
| 4 (default) | 24 kB/s | 72 kB/s | 216 kB/s (1.728 Mbps) | 72 kB/s | 2.16 Mbps |
| 8 (qualification ceiling) | 24 kB/s | 168 kB/s | 1,176 kB/s (9.408 Mbps) | 168 kB/s | 11.76 Mbps |
| 16 (not supported initially) | 24 kB/s | 360 kB/s | 5,400 kB/s (43.2 Mbps) | 360 kB/s | 54 Mbps |
These costs make four the sensible default; eight needs real upload/render qualification. First lower distant actor updates to 10 Hz or reduce admitted capacity if needed, with a fresh absolute state on re-entering full relevance. Do not promise 100 players because Steam's lobby can contain them. Direct mesh could reduce the host's eight-player upload from 49 streams to seven, at the cost described in §4.
Let `S` be one local full simulation tick cost, `A` one remote pose reconstruction cost, and `R` one avatar's draw/skinning cost. Initial per-machine physics is **60S/s**, remote pose work approximately **30(N−1)A/s** plus render interpolation, and rendering grows approximately with N avatars/attachments (`R` depends on frame rate/culling). Host networking is O(N²) forwarding bytes but need not re-encode each packet for each recipient: validate once and share an immutable buffer. Host-authoritative alternative costs approximately **60NS/s** before prediction/replay. No milliseconds/player or supported hardware count has been measured. Profile actual graph evaluation, skinning and scene draw costs separately.
## 10. Bevy integration, queues and failures
**R:** Bevy fixed schedules may run zero or multiple times in a frame. `Time<Real>` is independent of virtual pause. The inspected Bevy Steamworks plugin pumps callbacks in `First`, not `FixedUpdate`. [Bevy 0.18.1 FixedUpdate](https://docs.rs/bevy/0.18.1/bevy/app/struct.FixedUpdate.html), [real time](https://docs.rs/bevy/0.18.1/bevy/time/struct.Real.html), [plugin source](https://docs.rs/bevy-steamworks/0.16.0/src/bevy_steamworks/lib.rs.html). Steam invokes callbacks on the thread pumping them. [Callback contract](https://partner.steamgames.com/doc/sdk/api#callbacks).
**P:** start with one application-owned main-thread Steam pump; Steam's internal network workers remain its concern. No Tokio runtime and no full simulation worker per connection. Callbacks copy small typed events into a bounded session inbox, never run physics, asset loading, or arbitrary Bevy world mutation while Steam is dispatching.
| Schedule/boundary | Integration |
|---|---|
| Before `DefaultPlugins` | Optional Steam initialization and callback registration; hold callback handles, client and connection owners for their required lifetimes. Initialize relay access before the player waits to join. |
| `First` | Pump Steam exactly once; accept/reject connection requests; receive a bounded round-robin batch; validate and enqueue session/actor updates. This runs in solo menus, connected menus, loading and replay states. |
| `PreUpdate` | Existing controller polling; process typed session transitions and neutral-input gating before publication. Drain reliable lifecycle messages into persistent state before simulation. |
| `FixedUpdate` | Apply accepted map/spawn commands at boundaries; keep Input→Controls→Physics ordering; export a completed immutable local net snapshot after Physics. Do not send every catch-up tick. |
| `Update` | Render remote actor histories on network time; update menu/loading/error view models and complete asynchronous local asset work. No remote physics solve. |
| `Last` | Flush urgent control and newest due actor messages with budgets; advance real-time deadlines and metrics even without fixed ticks. |
While connected, configure window/update behavior so minimized/unfocused windows still pump at a bounded cadence (target ≤33 ms), while gameplay focus rules neutralize controls. Loading must be asynchronous and keep the app updating. If a future loader necessarily blocks the main thread for seconds, move the single Steam transport owner and its callback pump to a dedicated bounded I/O worker first; do not introduce a second callback pump alongside it. GPU asset publication remains on Bevy's appropriate path.
**Initial bounds:** up to eight admitted players, at most eight additional pending handshakes globally; 64 received messages/128 KiB per frame globally, round-robin by peer, with per-peer limits so one flood cannot starve control. Maintain latest three pending state packets per actor, 32 interpolation snapshots, ≤64 KiB application reliable queue per peer, ≤32 KiB total definition reassembly per peer, and ≤128 KiB Steam send buffer per connection. Definition object≤4 KiB, chunks≤1 KiB, at most eight in-flight objects/peer, 5 s reassembly deadline. Rate-limit routine reliable definition/cosmetic output to 8 KiB/s/actor with a 16 KiB burst; prioritize session control. These are proposed caps, not upstream defaults. Configure available Steam send/receive limits and close sustained floods before unbounded third-party buffers grow; the app's inbox bound alone is not a bound on Steam's internal queues.
Use persistent bounded resources for session state/acks rather than messages that a paused simulation may never read. On state backpressure, replace older unsent state with newest. On reliable overflow, stop admission/cosmetic changes or disconnect the slow peer with `SlowConsumer`; never silently discard map/spawn commits. Track queue age as well as byte count. If Steam returns a failed send result, handle it; successful enqueue is not proof of remote delivery.
**Packet validation before allocation or entity access:** header length/magic/version, message kind allowed for connection phase, exact payload size, count multiplication overflow, catalog/slot ownership, current revisions, monotonic sequence window, finite floats, valid quaternion/normal/ranges, plausible timestamps, map geometry bounds and spawn authority. Validate animation stack depth/arity/opcodes and catalog existence **before** calling the pose evaluator. Recipe strings are not asset paths. Cap UTF-8 names and strip control characters; sanitize logs and escape UI content. No untrusted packet can select a Bevy entity directly, trigger process execution, request file reads, or make the entire app call `AppExit`.
### Failure policy
| Condition | Initial behavior |
|---|---|
| Steam not running/signed in, bad AppID/license/context | Solo remains usable if the runtime DLL is present; Multiplayer shows a specific diagnostic and Retry. Do not auto-launch Steam. |
| Lobby unavailable/full/private, owner changed, wrong project/protocol | Fail the join before loading/spawn; release reservations, leave lobby and return to Multiplayer. Re-read metadata on retry. |
| Connect/handshake stalls | Initial 15 s transport/10 s application-handshake budgets; display the current stage and Cancel. Steam lobby search itself can take longer; allow its documented timeout and tag late results with attempt generation. |
| Missing map/rig/animation or gameplay digest mismatch | Show expected versus local version/hash suffix and asset category. No automatic asset transfer, silent map substitution, or repeated retry loop. |
| Local load/map barrier stalls | Show loading stage/progress; proposed 120 s deadline, cancelable. Abort before commit or remove the failing member after commit, following §8. |
| Network disruption | Mark actor stale at 250 ms; after 3 s without authenticated traffic show reconnecting; at 10 s remove guest/end host session. Transport failure callbacks can trigger cleanup earlier. No automatic creation of a new session under the old ID. |
| Steam backend temporarily disconnects | Stop new admission. Existing authenticated connections may continue if still healthy; show degraded Steam status. Revalidate membership when backend returns. Transport liveness and lobby-service liveness are distinct. |
| Guest departure/crash | Host closes its connection, increments roster revision, despawns its actor/board/attachments, and frees reservations. A returning SteamID completes a new handshake and receives a new generation. |
| Host departure/crash | End v1 session. On graceful exit: close joinability, send `SessionEnded(HostLeft)`, bounded flush (≤500 ms), close/listen cleanup, leave. Guests return to solo and can host a new session explicitly. |
| Invalid actor output | Drop invalid packet and record reason/counter; repeated violations remove that peer. Local simulation failure follows the existing local failure policy; a malformed remote must not terminate all players. |
**R/P:** Steam automatically assigns a new lobby owner when its owner leaves. That is **not game-host migration**. Guests pin the accepted host/session; they do not accept authority because their lobby ownership changed. On host loss, all cooperative clients leave the old lobby; if one became its owner, it can mark it closed before leaving. Steam destroys an empty lobby. Old metadata can briefly persist while membership settles, so always revalidate joins. Migration would need authority election, transport rerouting and a complete shared-session checkpoint; defer it. [Lobby ownership](https://partner.steamgames.com/doc/api/ISteamMatchmaking#GetLobbyOwner), [lobby lifetime](https://partner.steamgames.com/doc/features/multiplayer/matchmaking).
Diagnostics: typed `SteamInit`, `RelayUnavailable`, `AuthenticationFailed`, `LobbyFull`, `VersionMismatch`, `MissingContent`, `InvalidPacket`, `SlowConsumer`, `LoadFailed`, `HostLeft`, `TimedOut`; concise actionable UI text plus stage/attempt ID. Local logs record protocol/build, connection state, sanitized Steam reason code, RTT/jitter/loss, send-queue age, snapshot age, dropped/coalesced counts and map/recipe revision. Keep private identifiers/tickets/paths out of public diagnostics and source control.
## 11. Modules, milestones and verification gates
### Proposed ownership and narrow interfaces
| Module/crate | Responsibility and integration seam |
|---|---|
| New `crates/skate-net` | Owned wire types/codec, limits, IDs, sequence arithmetic, bounded interpolation timing, protocol/session state machine; no Bevy renderer or Steam dependency. Synthetic tests can run without Steam or game assets. |
| New `crates/skate-game/src/multiplayer/{steam,session,menu,replication,remote_actor,diagnostics}.rs` | Steam transport/lobby lifecycle; per-player registry and readiness; multiplayer UI view model; export/consume snapshots; remote skin instances. One plugin joins existing schedules. Keep Steam binding details confined to `steam`. |
| Existing input/physics owners | Preserve local polling/steps. Expose a read-only tick output and accepted spawn/reset command boundary. An optional later input audit observes `TickInput`; it never reads devices on behalf of remote actors. |
| Existing animation owners | Retain evaluated command recipe and export physical publication by tick. Add a read-only remote evaluator/binding path. Split immutable clip/rig data from per-actor mutable presentation, not the whole simulation into ECS components. |
| Map-switch task | Own `MapDescriptor`, canonical IDs/digests, `prepare/commit/cancel` handles, installed-map lookup and fixed-boundary spawn reset. Network session survives commits and receives completion/failure notifications. |
| Character task | Own canonical bounded manifest, rig/presentation compatibility, local asset resolution/fallback and per-actor attachments. Expose physics-affecting versus cosmetic change classification. |
| Plant/climb task | Export semantic mode/contact/phase plus any post-pose modifier needed by the presentation contract. Stable anchor IDs depend on map epoch. |
| Renderer/menu/replay tasks | Per-player skin/root ownership, shared immutable resources, normal asset teardown, online pause/replay policy, and a Multiplayer navigation entry. No network changes to material/lighting internals. |
These are proposed contracts for the responsible tasks, not edits to their modules in this planning task. Do not copy source from the old custom-engine multiplayer or use it to shortcut export contracts.
### Milestones (subsequent explicit implementation request required)
1. **Freeze scope and audit presentation boundaries.** Confirm free skate/ghost collisions/no migration/default four. Reconcile newer map/character/plant changes against this baseline. Inspect representative current command recipes and read-only physical publication; count recipe sizes and validate the proposed 1,200 B encoding without a network. Gate: two independently bound synthetic/local presentation actors, no shared mutable skin state, and a complete exported-state inventory. This is the biggest visual risk.
2. **Protocol and session core with fake transport.** Implement bounded codec, identity/revision rules, handshakes, queue policies, map transaction and disconnect state machine. Gate: malformed/count-overflow/NaN/unknown-ID packets rejected; stale callbacks and reordered state/definitions cannot spawn the wrong actor or revive a cancelled session. Use synthetic data; no Steam/game process needed.
3. **Steam bootstrap/lobby/transport only.** Add pinned dependency/feature/package handling, a single early initialization/pump, UI Host/Browse/Join/Leave and typed errors. Gate: no input API/manifest integration; no old networking APIs; source review confirms lane-0 use and correct callback lifetimes. Later user-run Steam check uses two distinct accounts/machines, not two local processes sharing one Steam identity.
4. **Two-player free skate.** Export/route absolute state and recipes; instantiate non-colliding remote actors; implement interpolation and lifecycle events. Gate: local direct-input behavior unchanged, synchronized board/rig/stance, no camera/root ownership crossover, remote bails/grinds/offboard/plants visible without a full bone palette. User visual verdict is separate from telemetry results.
5. **Content/lifecycle integration.** Add late join, customisation revisions, map barrier/abort and host departure. Gate: incompatible content fails clearly, cancellation cleans resources, map switch retains session without launching another process, and no assets transfer over network.
6. **Capacity and adverse conditions.** Qualify four then eight players, rendering/host upload and background/load pumping. Gate: bounded memory, useful errors, graceful slow-peer handling and recorded latency/jitter/loss costs. Set public capacity from actual measurements; don't increase it because synthetic packet forwarding alone is fast.
### Later verification plan
No tests/builds are needed to approve this research document. During implementation, run meaningful non-game protocol tests and source/data checks once per change. Do not launch the game, recomp or `--check-assets` as an automated validation process. When actual gameplay testing is needed, prepare a named task-specific Windows launcher using existing installed content and let the user run it; report their visual verdict separately. Do not install SDKs, change Steam/controller settings or launch Steam on the user's behalf.
- **Controller causal matrix:** user tests direct executable/offline, direct executable with Steam 480 initialized, and Steam-associated launch; external per-game Steam Input disabled versus the existing enabled profile; Xbox/XInput device first, then any other currently supported device; unplug/replug, overlay, focus changes, and entering/leaving Multiplayer. Compare raw XInput slot/status/axis/button publication and derived action timing, without adding Steam Input introspection. Record the exact device/driver/client version and launch association locally. Success is unchanged direct controls with the external override where necessary, not merely successful Steam initialization.
- **Skating visual matrix:** pushes/crouch, fast flick transitions, flips/shoves/spins, each grind family and grind-out, bails/recovery, offboard walk/run/carry/remount/free board, new plants/climbing, teleports, different stance/posture/outfits. Check 24-part reconstruction against local output; investigate errors before changing bandwidth strategy.
- **Network matrix:** two actual accounts on separate machines; relay route with different NATs; proposed 0/50/100/200 ms latency cases with directions clearly defined, jitter, 1/5/10% loss, duplication/reorder, 0.5/3/10 s stalls, slow upload, render stalls/minimize, Steam backend loss, abrupt host/guest exit. Use synthetic transport fault injection before any user-run game check. No measured latency claims until tests exist.
- **Lifecycle/content matrix:** late join mid-grind/bail, full/private lobby, duplicate identity, cancelled join with late callback, protocol mismatch, missing or changed map, map-load failure before/after commit, stale map packets, missing cosmetic fallback, forbidden physics profile, repeated joins/leaves and no remaining entities/callbacks/lobbies.
- **Performance:** measure encoded packet size distribution, recipe churn, transport overhead/queue delay, host forwarding CPU, per-actor pose/skin/draw CPU/GPU, bounded memory and asset teardown. Test all players near one another as well as spread around the map; distance culling alone cannot establish worst-case capacity.
### Highest-risk unknowns and user preferences
**U:** complete and economical remote physical-animation reconstruction; actual recipe complexity and quantization error; map staging/commit and rig lifecycle interfaces after concurrent work; controller behavior under the user's actual Steam profile; Spacewar service/invite behavior; and supported capacity on real host upload/GPU. All require later verification. None justify implementing old hooks or full bone streaming by default.
**Preference that changes architecture:** whether the first release must have physical player collisions or trusted competitive outcomes. This plan assumes **no**. If required, approve the host-authoritative checkpoint/prediction prerequisite before implementation. Secondary optional preferences are session size and whether host departure should terminate the session; the concrete proposed defaults are four players (qualify eight) and termination. No answer is required merely to review this document.
**Distribution prerequisite, not a gameplay preference:** the project's own Steam AppID, Valve permissions/configuration and content/branding rights must be established before a production Steam release. This plan does not authorize publishing through Spacewar.