ZOA Networking
Backend-agnostic replication, ownership, prediction, and session authority, with FishNet as the one supported transport.
Networking is where the architectural decisions in ZOA.Foundry become load-bearing. Gameplay code never imports a transport SDK. It talks to a small set of facades defined here: INetworkingBackend for session lifecycle, IStateChannel for replicated state, INetworkSpawner for authoritative instantiation, IPlayerStateChannel for per-tick player snapshots, and INetworkedDamageChannel for server-validated damage. Backend adapters implement those facades behind a per-backend assembly definition and register themselves at runtime.
The rule is enforced in CI. A self-scan test, BackendLeakSelfScanTests.NoBackendImports_OutsideNetworkingPackage, fails the build when any package outside com.zoa.networking imports a backend SDK, with a shrinking KnownDebt allowlist for pre-existing violations. The test keeps the dependency graph honest as new adapters land.
One backend is covered by the 1.0 support promise. FishNet ships as a first-class INetworkingBackend built on real FishNet 4.x APIs. Netcode for GameObjects, Netick, Mirror, Photon Fusion, and PUN 2 ship as experimental reflection-based presence and diagnostics adapters, and the local loopback backend is internal development infrastructure that is never a shippable transport. NetworkBackendSupportMatrix in com.zoa.networking.abstractions is the single authoritative declaration of that policy, and NetworkingSessionRunner logs a warning whenever a session resolves to a backend outside it.
Single-player keeps working with no adapter at all. When none is registered, NullNetworkingBackend answers the facade, InProcessPlayerStateChannel and InProcessNetworkedDamageChannel loop the pub/sub back to the caller, and UnityInstantiateSpawner falls back to Object.Instantiate. Domain code never branches on whether a backend exists.
Depended on by (2)
How it works
Concepts
Transport is split from replication
INetworkingBackend owns only the session lifecycle: Initialize with an INetworkingSessionConfig, then StartHost, StartClient, or StartServer, then Shutdown. It reports a NetworkingSessionState, exposes IsServer and IsClient, and raises RemotePeerJoined and RemotePeerLeft alongside a LocalPeerId. Peer ids are the backend's native connection id stringified, so ids are comparable across backends inside one process.
Replication, ownership, and prediction live in separate services that are pure C# and already implemented in the Core assembly. ZOA.Networking.Core is compiled with noEngineReferences, so it holds no UnityEngine types at all and every backend integration has only transport to bridge. Demos and tests then target one API whichever SDK is installed, and Foundry ships with zero third-party dependencies.
The engine-shaped part of the surface lives in ZOA.Networking.Unity instead. INetworkSpawner surfaces GameObject, Vector3, and Quaternion, so it could not live in Core. There is a tombstone file at Runtime/Core/Spawn/INetworkSpawner.cs recording that move.
State channels carry the replication policy
A state channel is a named slot of replicated bytes with a policy attached. StateChannelDefinition binds a StateChannelId to an OwnershipMode, a PredictionPolicy, a ReplicationPriority, a minimum update interval in seconds, and a reliability flag. Build one with the fluent StateChannelDefinition.CreateBuilder, register it with IReplicationService.RegisterChannel, and the registry materialises a StateChannel that stores the latest ReplicationSnapshot.
DefaultStateChannels names the standard ids so packages agree without sharing types: transform, inventory, equipment, weapon-state, health, survival, interaction, ability, condition, crafting, and player.state. StateChannelId is a case-insensitive readonly struct with implicit conversion both ways to string, so a literal works wherever an id is expected.
Writes are ordered and gated. StateChannel.TryWrite rejects a null payload, rejects a writer that fails the ownership check, and rejects any tick older than the last accepted snapshot, so a late packet cannot rewind a channel.
Ownership decides who may write
OwnershipMode has four values and each one means something specific at the write site. ServerOnly means the default StateChannel refuses every TryWrite, because server-side writes are expected to bypass the channel's client-facing path entirely. OwnerPredicted accepts writes only from the peer that currently holds CurrentOwnerId. SharedAuthority accepts writes from anyone and expects the surrounding system to coordinate. ObserverOnly is read-only.
IOwnershipManager sits above the registry and tracks the owner per channel. RequestTransfer consults the channel's definition: ServerOnly and ObserverOnly channels refuse transfer outright, while OwnerPredicted and SharedAuthority allow it. Every accepted transfer raises OwnershipChanged with the channel, the old owner, and the new owner, so presentation code can rebind without polling.
IReplicationValidator is the separate policy check you run before accepting a snapshot from the wire. ValidateOwnership returns a coded ReplicationValidationResult such as NOT_OWNER or READ_ONLY, ValidateSnapshot rejects empty payloads with EMPTY_PAYLOAD, and ValidateTick rejects drift beyond a caller-supplied bound with TICK_TOO_OLD or TICK_TOO_FAR. Codes are stable strings, so you can log and aggregate them.
Prediction is a store, a reconcile, and a purge
IPredictionService keeps predicted payloads keyed by channel and tick. The owning client calls StorePrediction after it applies its own input locally. When the server snapshot for that tick arrives, TryReconcile compares the stored prediction byte for byte and returns true only when they diverged, handing back the server state as correctedState. A false return means the prediction was right and nothing needs rewinding.
PurgePredictionsBefore drops everything up to and including an acknowledged tick, and PendingPredictionCount tells you how deep the outstanding queue is. Those two together are the backpressure signal: a queue that keeps growing means acknowledgements are not arriving.
PredictionPolicy on the channel definition declares the intent rather than performing it: None, ClientSidePrediction, ServerReconciliation, or Interpolation. Observers of a remote rig use the interpolation path, which is implemented concretely by RemotePlayerSyncBridge rather than by the prediction service.
The player snapshot pipeline
PlayerStateSnapshot is the per-tick payload of the player.state channel, a struct of plain floats. Position and rotation are world space, look angles are degrees, velocity is metres per second, and none of it uses a Unity type, so the layout serialises identically across backends and the ring buffer stays quiet at fifty hertz times the peer count.
LocalPlayerStatePublisher sits on the local rig at execution order 8000 and samples transform, velocity, look angles, and CharacterController.isGrounded each FixedUpdate. Crouch, sprint, aim-down-sights, firing, reloading, jumping, lean, and death are not read by the publisher: gameplay components push them in through SetLocomotionFlag, which keeps com.zoa.networking free of a reverse dependency on the player package. SetActiveWeaponSlot and SetActiveWeaponId carry the equipped weapon.
Weapon identity crosses the wire as an int, not a string. WeaponNetworkId.Hash computes an FNV-1a 32-bit hash of the weapon's DefinitionId, because string.GetHashCode is seeded per process in modern .NET and host and client would never agree on it. Zero means unarmed, and a real id that would hash to zero is nudged to one.
RemotePlayerSyncBridge at execution order 8500 consumes the other side. Each bridge is bound to exactly one peer id and ignores everything else. It keeps a four-entry ring buffer inserted in Tick order rather than arrival order, samples at Time.time minus renderDelay, defaulting to 100 ms, and lerps and slerps between the two bracketing entries. Fewer than two bracketing entries and it snaps to the newest snapshot. Snapshots older than the oldest buffer entry are dropped. The locomotion bitmask drives animator bools by name: Grounded, Crouched, Sprinting, AimingDownSights, Firing, Reloading, Jumping, LeanLeft, LeanRight.
Damage is two-phase and server-stamped
INetworkedDamageChannel routes two messages, a request and a command, and holds no replicated state of its own. A shooter that hits a remote puppet calls RequestDamage, which marshals to the server. The server, and only the server, raises DamageRequestedOnServer, with AttackerPeerId stamped authoritatively from the sending connection so a client cannot claim to be someone else.
After the server has validated the request, it calls IssueCommand. Every peer including the host then raises DamageCommandReceived, and the peer whose local rig matches TargetPeerId applies the damage to its own rig. Death is not a separate message: it replicates as the Dead bit in the next PlayerStateSnapshot, which the remote bridge latches on to freeze the puppet at its death pose.
EnsureReady eagerly binds the underlying transport handlers so the server can receive requests before it has sent anything. It is idempotent and safe to call every frame, and it is a no-op for the in-process implementation.
Session profiles and trust tiers
Session behaviour is data. An ISessionProfile carries a topology, a trust tier, a ruleset lock level, whether mods are allowed, and a progression trust policy. SessionProfileCatalog ships three defaults: free-host is a listen host with HostLocal trust, an open ruleset, mods allowed, and LocalOnly progression; community-dedicated is a dedicated server with CommunityDedicated trust, a community-locked ruleset, mods allowed, and ServerLocal progression; competitive-dedicated is a dedicated server with CompetitiveDedicated trust, a competitive-locked ruleset, mods refused, and CompetitiveSigned progression.
SessionProfileResolver maps a SessionBootstrapMode plus a licence flag onto one of those. Host resolves to free-host. Dedicated resolves to competitive-dedicated when a competitive licence is present and to community-dedicated otherwise. Legacy asset ids steam-host-authoritative and steam-dedicated-server-authoritative are canonicalised onto free-host and community-dedicated, so existing serialised NetworkSettings keep resolving.
Separating trust from topology lets a free host session and a dedicated session share one simulation and one set of rules while differing in how far their results are believed. Progression from a listen host is local only; progression from a dedicated server is server-local; competitive results are signed.
One simulation, two authority shells
ListenHostAuthorityRuntime and DedicatedServerAuthorityRuntime both implement IAuthorityRuntime, so the same commands, snapshots, events, and validation rules run in either shell. Forking the gameplay code into a host build and a dedicated build was rejected: two forks drift apart permanently.
The listen-host runtime owns admission and readiness. TryAdmitOrRejoinPlayer enforces the party size cap and honours a rejoin window measured in seconds, restoring the player's HostPlayerRuntimeState instead of treating them as new. TryBeginSceneTransition and TryAcknowledgeSceneLoaded gate the match behind every connected player reporting the same scene, and IsMatchReady only returns true when nothing is pending and every connected player is both ready and scene-loaded. TryApplyAuthorityEvent is host-only, and BuildLateJoinSnapshot reconstructs the SessionStateSnapshot a late joiner needs. Termination is explicit through HostRuntimeTerminationReason, set by Stop, CrashHost, or EndSession.
The dedicated runtime adds process concerns. StartFromCommandLine parses a DedicatedServerStartupRequest into a DedicatedServerLaunchConfig covering bind address, game port, query port, max players, password, and ruleset id, then produces a DedicatedServerStartupSummary carrying the resolved profile, protocol version, ruleset hash, and licence status, with ToLogLine for the operator's first line of output. TryBeginMatch and TryEndMatch bracket a match id.
Persistence boundaries differ by topology
Multiplayer splits persistence into three scopes and this package enforces two of them. LocalHostProgressionBoundary refuses progression writes that do not come from a listen-host topology with a local-trust policy, unless it was constructed with allowNonLocalPolicies, and it deduplicates by CompletionId so a retried commit does not double-award.
DedicatedServerPersistenceBoundary is the mirror on the server side. It refuses writes from a non-dedicated topology and makes both TryWriteServerState and TryWriteMatchLog idempotent per id, exposing PersistedStateCount and PersistedMatchLogCount. Both boundaries sit in front of an interface, IHostProgressionStore and IDedicatedServerPersistenceStore, with in-memory implementations shipped for tests.
SteamCloudSyncPolicy is a declarative allowlist plus exclusion list over relative paths. CreateFoundryHostDefaults produces the Foundry defaults, IsEligibleForCloudSync answers per path, and DescribeSupportPaths prints the effective policy for a support ticket. Explicit cloud eligibility stops a server-local artefact from silently syncing to a player's Steam Cloud.
Backends are gated by two defines
FishNet ships as an Asset Store package with no UPM id, so asmdef versionDefines cannot target it. The integration therefore requires two scripting defines and the per-backend asmdef carries both as defineConstraints. ZOA_NETWORK_FISHNET_AVAILABLE is set automatically by FishNetPresenceDefineSetter, an InitializeOnLoad type that probes the AppDomain for FishNet.InstanceFinder. ZOA_NETWORK_FISHNET_ENABLED is toggled by the user in the Foundry Extensions browser.
When either define is missing Unity skips the assembly entirely and every file inside it contributes zero bytes, regardless of the using statements at the top. Gating happens at the assembly level, with no source-level conditional compilation anywhere. The four states are clean. No SDK and no opt-in compiles nothing. SDK present without opt-in compiles nothing and the browser offers Enable integration. Opt-in without the SDK compiles nothing and the browser offers Install asset. Both present compiles the adapter, which auto-registers its backend and spawner.
ZOA_NETWORK_FISHNET_MANUAL is the escape hatch for power users who want to manage the AVAILABLE define themselves, and FishNetPresenceDefineSetter migrates the older ZOA_FISHNET_PRESENT and ZOA_FISHNET_* define names forward.
In the editor
Screens
Screenshot pending
/screenshots/networking-workbench.png
Capability strip visible with Create Assets, Multiplayer Setup, State Channels, Ownership, Validation, and Overview, with the state channel browser open showing several registered channels and their ownership modes.
Screenshot pending
/screenshots/networking-multiplayer-wizard.png
The wizard shell on the distributed step, showing backend id, session mode, address, port, max players, and the attach-runner toggle, with the save path filled in.
Screenshot pending
/screenshots/networking-performance-hud.png
ZOANetworkPerformanceHUD running in play mode with the backend, role, and status block filled in and two or three expanded component rows showing their throughput graphs.
Screenshot pending
/screenshots/networking-extensions-browser.png
The Networking category with the FishNet row showing its diagnostic summary and the Enable integration action, ideally captured in the SDK-present but not-yet-enabled state.
Setup
Workflow
- 01
Install the backend, then opt in
Import FishNet into the project. FishNetPresenceDefineSetter detects FishNet.InstanceFinder and sets ZOA_NETWORK_FISHNET_AVAILABLE on its own. Open Tools/ZOA/Foundry Extensions, find the FishNet Networking row, and run its setup to toggle ZOA_NETWORK_FISHNET_ENABLED. Only with both defines set does the FishNet assembly compile and auto-register the backend and spawner.
- 02
Create the project-level assets
From the Networking module in the Workbench, or from Tools/ZOA/Advanced/Define/Networking, create NetworkSettings at Assets/Resources/ZOA/NetworkSettings.asset, create the default session profiles, and create the network prefab catalog. Apply Foundry Defaults sets the FishNet backend with a Steam-first host-authoritative topology in one action.
- 03
Author a session config
Create a NetworkingSessionConfig asset, or run the Multiplayer Setup wizard which authors one for you and can attach the runner to the active scene. Pick the backend id, the topology, the bind or connect address, the port, and the player cap. Leave the backend id as null while you are still working single-player. The runner reads that as a chosen loopback.
- 04
Drive the session from a scene
Add a NetworkingSessionRunner to a bootstrap object and assign the config. Leave autoStart on for demos, or turn it off and call StartSession yourself with per-session overrides when the topology comes from a lobby. The runner shuts the backend down on OnDestroy, so scene teardown is not something you have to remember.
- 05
Register the channels your systems replicate
Build a StateChannelDefinition per replicated concern, using the ids in DefaultStateChannels where one fits, and register each through IReplicationService. Pick the ownership mode first: it determines whether client writes are accepted at all, and it is the field most often set wrong.
- 06
Wire the player rigs
Put a LocalPlayerStatePublisher on the local rig and a RemotePlayerSyncBridge on each remote puppet, with BoundPeerId set to the peer that puppet represents. Have the rig's gameplay components call SetLocomotionFlag, SetActiveWeaponSlot, and SetActiveWeaponId as their state changes. Tune renderDelay upward on jittery networks and downward on a LAN.
- 07
Gate the join
Before loading a gameplay scene from a discovered session, build a ConnectToken through the discovery adapter and run NetworkSessionBootstrap.TryPrepareJoin against the authority's protocol version, build version, profile id, mod state, and content hash. Surface the returned reason string directly: it is written to be shown to a player.
Surface
Key types
INetworkingBackend
interface
Transport-level session lifecycle. The only facade a backend adapter must implement to be usable. Replication, ownership, and prediction are separate services.
- string Id { get; }
- string DisplayName { get; }
- NetworkingSessionState State { get; }
- bool IsServer { get; }
- bool IsClient { get; }
- string LocalPeerId { get; }
- string LastError { get; }
- event Action<NetworkingSessionState> StateChanged
- event Action<string> RemotePeerJoined
- event Action<string> RemotePeerLeft
- void Initialize(INetworkingSessionConfig config)
- void StartHost() / StartClient() / StartServer() / Shutdown()
NetworkingBackendRegistry
service
Process-wide lookup table of registered backends. Active never returns null; it falls back to NullNetworkingBackend, and IsRealBackendActive tells the two apart.
- static INetworkingBackend Active { get; }
- static bool IsRealBackendActive { get; }
- static void Register(INetworkingBackend backend)
- static void Unregister(string id)
- static void SetActive(string id)
- static INetworkingBackend ResolveOrNull(string id)
- static IReadOnlyList<string> RegisteredIds()
NullNetworkingBackend
class
Single-player loopback backend. Transitions the lifecycle synchronously, reports IsServer and IsClient both true while running so authority branches take the authoritative path, and reports LocalPeerId "0".
- static readonly NullNetworkingBackend Instance
INetworkingSessionConfig
interface
Read-only session start-up settings. Lives in Core so backends can read configuration without referencing any Unity type.
- string BackendId { get; }
- NetworkingMode Mode { get; }
- string Address { get; }
- ushort Port { get; }
- int MaxPlayers { get; }
- string SessionName { get; }
NetworkingSessionRunner
component
Scene driver that resolves a backend, initialises it from the config, and starts the requested topology. Degrades to LocalOnly with a warning when no real backend is registered, so a scene still plays without FishNet installed.
- INetworkingBackend Backend { get; }
- NetworkingSessionConfig Config { get; set; }
- bool HasRealBackend { get; }
- void StartSession(NetworkingMode? modeOverride = null, string addressOverride = null, ushort? portOverride = null, int? maxPlayersOverride = null, string sessionNameOverride = null, string backendIdOverride = null)
- void StopSession()
IStateChannel
interface
One named slot of replicated bytes. Enforces the definition's ownership mode on write and refuses ticks older than the last accepted snapshot.
- StateChannelId Id { get; }
- StateChannelDefinition Definition { get; }
- string CurrentOwnerId { get; }
- uint LastTick { get; }
- bool TryWrite(string writerId, byte[] payload, uint tick)
- bool TryRead(out ReplicationSnapshot snapshot)
- void SetOwner(string ownerId)
StateChannelDefinition
class
The policy attached to a channel: ownership, prediction, priority, update interval, reliability. Build it fluently rather than through the positional constructor.
- static Builder CreateBuilder(StateChannelId id)
- Builder.WithDisplayName / WithOwnership / WithPrediction / WithPriority / WithUpdateInterval / WithReliable
- StateChannelDefinition Build()
StateChannelId
struct
Case-insensitive channel identifier, lowercased on construction, with implicit conversion to and from string so a literal works anywhere an id is expected.
DefaultStateChannels
class
The standard channel ids, so packages agree on names without sharing types: Transform, Inventory, Equipment, WeaponState, Health, Survival, Interaction, Ability, Condition, Crafting, and PlayerState.
OwnershipMode
enum
ServerOnly, OwnerPredicted, SharedAuthority, ObserverOnly. Read it as a write rule: only OwnerPredicted and SharedAuthority accept a client write through the default channel.
PredictionPolicy
enum
None, ClientSidePrediction, ServerReconciliation, Interpolation. Declares the intent for a channel; the prediction service and the remote bridge perform it.
ReplicationPriority
enum
Critical, High, Normal, Low, Background. Ordered so lower numeric value means more urgent and less latency tolerance.
IReplicationService
interface
The channel-level API most gameplay code uses. Registers channels, writes through them, tracks which are dirty, and raises StateReplicated on every accepted write.
- void RegisterChannel(StateChannelDefinition definition)
- void UnregisterChannel(StateChannelId channelId)
- bool TryReplicate(StateChannelId channelId, string writerId, byte[] payload, uint tick)
- bool TryGetLatest(StateChannelId channelId, out ReplicationSnapshot snapshot)
- IReadOnlyList<StateChannelId> GetDirtyChannels()
- void AcknowledgeTick(StateChannelId channelId, uint tick)
- event Action<StateChannelId, ReplicationSnapshot> StateReplicated
IOwnershipManager
interface
Tracks and transfers channel ownership. Transfer is refused outright for ServerOnly and ObserverOnly channels.
- string GetOwner(StateChannelId channelId)
- bool IsOwner(StateChannelId channelId, string peerId)
- bool RequestTransfer(OwnershipTransferRequest request)
- event Action<StateChannelId, string, string> OwnershipChanged
IPredictionService
interface
Client-side prediction store and reconciliation. TryReconcile returns true only when the prediction diverged from the server payload.
- void StorePrediction(StateChannelId channelId, uint tick, byte[] predictedState)
- bool TryReconcile(StateChannelId channelId, uint serverTick, byte[] serverState, out byte[] correctedState)
- void PurgePredictionsBefore(StateChannelId channelId, uint tick)
- int PendingPredictionCount(StateChannelId channelId)
IReplicationValidator
interface
Pre-acceptance checks with stable result codes. Returns ReplicationValidationResult carrying IsValid, Code, and Message rather than throwing.
- ReplicationValidationResult ValidateSnapshot(ReplicationSnapshot snapshot, StateChannelDefinition definition)
- ReplicationValidationResult ValidateOwnership(string writerId, StateChannelDefinition definition, string currentOwner)
- ReplicationValidationResult ValidateTick(uint clientTick, uint serverTick, uint maxDrift)
ReplicationSnapshot
struct
One replicated payload with its channel id, logical tick, owner id, byte payload, and UTC timestamp.
PlayerStateSnapshot
struct
The per-tick player payload. Plain floats only, so it serialises identically on every backend: position, rotation, velocity, LookYaw, LookPitch, LocomotionFlags, ActiveWeaponSlot, ActiveWeaponId, and Tick.
LocomotionBits
enum
Flags packed into PlayerStateSnapshot.LocomotionFlags: Grounded, Crouched, Sprinting, AimingDownSights, Firing, Reloading, Jumping, LeanLeft, LeanRight, Dead. Additive by design, so older peers that never set a newer bit still deserialise.
IPlayerStateChannel
interface
Narrow pub/sub for player snapshots; it does not reuse IStateChannel. Publish tags the snapshot with the originating peer id, and subscribers filter on it.
- void Publish(string peerId, in PlayerStateSnapshot snapshot)
- event Action<string, PlayerStateSnapshot> SnapshotReceived
INetworkedDamageChannel
interface
Two-phase server-authoritative damage. Clients request, the server validates and issues, every peer receives, and the owning peer applies it to its own rig.
- void RequestDamage(in NetworkedDamageMessage message)
- event Action<NetworkedDamageMessage> DamageRequestedOnServer
- void IssueCommand(in NetworkedDamageMessage message)
- event Action<NetworkedDamageMessage> DamageCommandReceived
- void EnsureReady()
INetworkSpawner
interface
Network-aware instantiation. Server-gated backends must return null and log rather than throw when a client calls Spawn; that is a logic bug, not a runtime error.
- bool IsServer { get; }
- string SpawnerId { get; }
- GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation, string ownerPeerId)
- void Despawn(GameObject instance)
UnityInstantiateSpawner
class
Fallback spawner backed by Object.Instantiate and Object.Destroy. Reports SpawnerId "unity-instantiate" and IsServer true, because in single-player every spawn is the server's spawn.
LocalPlayerStatePublisher
component
Sits on the local rig and publishes a PlayerStateSnapshot every FixedUpdate. Gameplay components feed it state rather than the other way round, which keeps this package free of a dependency on the player package.
- void SetLocomotionFlag(LocomotionBits flag, bool on)
- void SetActiveWeaponSlot(sbyte slot)
- void SetActiveWeaponId(int weaponId)
- void PublishImmediate()
- string PeerId { get; }
RemotePlayerSyncBridge
component
Drives one remote puppet from one peer's snapshots, through a four-entry buffer interpolated at a configurable render delay. Latches death and surfaces it to higher layers.
- string BoundPeerId { get; set; }
- float RenderDelay { get; set; }
- bool IsDead { get; }
- int ActiveWeaponId { get; }
- float LookPitch { get; }
- bool AimingDownSights { get; }
- event Action Died / event Action Revived / event Action<int> WeaponIdChanged
ListenHostAuthorityRuntime
class
Authority shell for a player-hosted session: admission with a party cap, a rejoin window, scene-transition sync, readiness gating, host-only event finalisation, and late-join reconstruction.
- void StartAsHost(ISessionProfile profile, ulong hostPlayerId)
- bool TryAdmitOrRejoinPlayer(...)
- bool TryBeginSceneTransition(string sceneId, out string reason)
- bool TryAcknowledgeSceneLoaded(ulong playerId, string sceneId, out string reason)
- bool TrySetPlayerReady(ulong playerId, bool ready, out string reason)
- bool IsMatchReady { get; }
- bool TryApplyAuthorityEvent(...)
- SessionStateSnapshot BuildLateJoinSnapshot()
- void CrashHost(string message) / void EndSession(bool isVictory, string message)
DedicatedServerAuthorityRuntime
class
Authority shell for a headless server. Boots from the command line into a launch config, reports a startup summary with the ruleset hash and licence status, and brackets matches by id.
- DedicatedServerStartupSummary StartFromCommandLine(string[] args)
- void Start(ISessionProfile profile, DedicatedServerLaunchConfig launchConfig)
- bool TryBeginMatch(string matchId, out string reason)
- bool TryEndMatch(bool wasCompleted, string resultSummary, out string reason)
- static DedicatedServerStartupRequest BuildStartupRequestFromCommandLine(string[] args)
SessionProfileResolver
class
Maps a bootstrap mode plus licence state onto a session profile, canonicalising the legacy steam-host-authoritative and steam-dedicated-server-authoritative ids onto the current ones.
- static SessionProfileResolver CreateDefault()
- ISessionProfile Resolve(SessionBootstrapMode mode, bool hasCompetitiveLicense, string explicitProfileId = null)
NetworkSessionBootstrap
class
Join-time preflight. Resolves a profile, selects a transport, and evaluates a connect token against the authority's protocol version, build version, profile id, mod state, and content hash before any gameplay scene loads.
- static ISessionProfile ResolveProfile(SessionBootstrapMode mode, bool hasCompetitiveLicense, string explicitProfileId = null)
- static SelectedTransportAdapter SelectTransport(ISessionProfile profile, bool preferSteamTransport)
- static ProtocolCompatibilityResult EvaluateProtocol(ConnectToken token, NetworkProtocolVersion authorityProtocolVersion)
- static bool TryPrepareJoin(ConnectToken token, NetworkProtocolVersion authorityProtocolVersion, string authorityBuildVersion, string authorityProfileId, bool? authorityIsModded, string authorityContentHash, bool allowModdedMismatch, out ProtocolCompatibilityResult compatibility, out string reason)
LicenseCapabilityGate
class
Turns a verified ServerLicense into capability answers. An unlicensed server still runs community dedicated; the licence unlocks the competitive profile and a slot cap.
- bool CanActivateCompetitive { get; }
- int LicensedMaxSlots { get; }
- LicenseVerificationResult VerificationStatus { get; }
- LicenseValidationResult EvaluateProfile(ISessionProfile profile)
OfflineLicenseVerifier
class
Verifies a ServerLicense signature, expiry, and structure against an embedded RSA public key. Makes no network calls: a hosted entitlement service is a recurring cost this architecture avoids.
SteamCloudSyncPolicy
class
Declarative allowlist and exclusion list deciding which host-mode files are eligible for Steam Cloud, with a printable summary for support.
- static SteamCloudSyncPolicy CreateFoundryHostDefaults()
- bool IsEligibleForCloudSync(string relativePath)
- string DescribeSupportPaths()
NetworkPrefabPool
class
Warm-up and reuse for catalogued network prefabs, keyed by the catalog's deterministic prefab id.
- void WarmupAll(Transform parent = null)
- GameObject Spawn(string prefabId, Vector3 position, Quaternion rotation, Transform parent = null)
- void Despawn(GameObject instance, Transform poolParent = null)
- bool TryGetEntry(string prefabId, out NetworkPrefabEntry entry)
- int AvailableCount(string prefabId)
ZOANetworkSyncBase
component
Base class for components that report their own replication traffic. A component becomes visible to the performance HUD by implementing ComponentName, BytesSent, BytesReceived, IsOwner, IsServer, and BackendId.
FishNetBackend
class
The one release-supported adapter. Drives lifecycle through InstanceFinder.ServerManager and ClientManager, bridges FishNet connection events onto RemotePeerJoined and RemotePeerLeft, and registers FishNetNetworkSpawner and FishNetPlayerStateChannel alongside itself.
- const string BackendId = "fishnet"
Surface
Authoring assets
NetworkingSessionConfig
asset
The canonical authoring surface for one session. Plain data only: backend id, mode, address, port, max players, session name. CreateAssetMenu path ZOA/Networking/Session Config.
- static NetworkingSessionConfig CreateRuntimeInstance(string backendId = "null", NetworkingMode mode = NetworkingMode.LocalOnly, string address = "127.0.0.1", ushort port = 7777, int maxPlayers = 4, string sessionName = "ZOA Session")
NetworkSettings
asset
Project-level networking policy loaded from Resources at ZOA/NetworkSettings: active backend id, default session profile, default topology, and whether Steam transport is preferred. Menu path Tools/ZOA/Networking/Settings.
- static NetworkSettings Instance { get; }
- void ApplyFoundryDefaults()
- bool IsFoundryDefaultsCompliant(out string details)
- bool UsesHostAuthoritativeTopology { get; }
SessionProfileDefinition
asset
Asset form of ISessionProfile. Authored under Resources at ZOA/Networking/Profiles, where SessionProfileCatalog loads every instance and falls back to the three built-in profiles when the folder is empty.
- void ApplyTemplate(string id, string name, SessionTopology targetTopology, SessionTrustTier targetTrustTier, SessionRulesetLockLevel targetRulesetLockLevel, bool targetAllowsMods, ProgressionTrustPolicy targetProgressionPolicy)
NetworkPrefabCatalog
asset
Registry of network prefabs with a deterministic prefab id, a NetworkPrefabRole of Player, SwarmActor, LootActor, or SessionState, and a warm-up count. Loaded from Resources at ZOA/Networking/NetworkPrefabCatalog.
Usage
Examples
using System.Text;
using ZOA.Networking.Core;
var registry = new StateChannelRegistry();
var ownership = new OwnershipManager(registry);
var replication = new ReplicationService(registry, ownership);
var definition = StateChannelDefinition
.CreateBuilder(DefaultStateChannels.WeaponState)
.WithDisplayName("Weapon State")
.WithOwnership(OwnershipMode.OwnerPredicted)
.WithPrediction(PredictionPolicy.ServerReconciliation)
.WithPriority(ReplicationPriority.High)
.WithUpdateInterval(0.05f)
.WithReliable(false)
.Build();
replication.RegisterChannel(definition);
// OwnerPredicted accepts writes only from the current owner.
if (registry.TryGet(DefaultStateChannels.WeaponState, out var channel))
channel.SetOwner(backend.LocalPeerId);
var payload = Encoding.UTF8.GetBytes("{\"rounds\":29}");
if (!replication.TryReplicate(DefaultStateChannels.WeaponState, backend.LocalPeerId, payload, tick))
{
// Rejected: not the owner, null payload, or an out-of-order tick.
}using UnityEngine;
using ZOA.Networking.Core;
using ZOA.Networking.Unity;
public sealed class LobbyLauncher : MonoBehaviour
{
[SerializeField] private NetworkingSessionRunner _runner;
public void HostFromLobby(string sessionName, int maxPlayers)
{
_runner.StartSession(
modeOverride: NetworkingMode.Host,
maxPlayersOverride: maxPlayers,
sessionNameOverride: sessionName);
if (!_runner.HasRealBackend)
{
// The runner already logged the downgrade and started a
// LocalOnly loopback. Reflect that in the UI rather than
// pretending the lobby is live.
}
}
public void JoinByAddress(string address, ushort port) =>
_runner.StartSession(
modeOverride: NetworkingMode.Client,
addressOverride: address,
portOverride: port);
}using ZOA.Messaging;
using ZOA.Networking.Core;
using ZOA.Networking.Replication;
// The publisher resolves the channel itself and registers the
// in-process loopback when nothing else has. A backend adapter
// substitutes its own implementation before consumers Awake.
if (!FoundryServiceRegistry.TryResolve<IPlayerStateChannel>(out var channel))
{
channel = new InProcessPlayerStateChannel();
FoundryServiceRegistry.Register<IPlayerStateChannel>(channel);
}
channel.SnapshotReceived += (peerId, snapshot) =>
{
if (peerId == localPeerId)
return; // A snapshot for our own rig means the bridge is bound wrong.
var dead = (snapshot.LocomotionFlags & (uint)LocomotionBits.Dead) != 0;
var sprinting = (snapshot.LocomotionFlags & (uint)LocomotionBits.Sprinting) != 0;
};
// Gameplay pushes state into the publisher rather than the publisher
// reading gameplay types, which keeps the dependency one-directional.
publisher.SetLocomotionFlag(LocomotionBits.Reloading, true);
publisher.SetActiveWeaponId(WeaponNetworkId.Hash(weapon.DefinitionId));using ZOA.Messaging;
using ZOA.Networking.Replication;
var damage = FoundryServiceRegistry.Get<INetworkedDamageChannel>();
damage.EnsureReady();
// Server side: validate, then issue the authoritative command.
damage.DamageRequestedOnServer += request =>
{
if (!IsPlausibleHit(request))
return;
// AttackerPeerId was stamped from the sending connection, so it
// is trustworthy here even though the rest of the payload is not.
damage.IssueCommand(request);
};
// Every peer, including the host, receives the command.
damage.DamageCommandReceived += command =>
{
if (command.TargetPeerId != localPeerId)
return;
ApplyToLocalRig(command.Amount, command.HitRegion);
};
// Client side: a hit on a remote puppet is a request, never a result.
damage.RequestDamage(new NetworkedDamageMessage
{
TargetPeerId = victimPeerId,
Amount = 24f,
BaseAmount = 24f,
Multiplier = 1f,
HitRegion = "torso",
HitX = point.x, HitY = point.y, HitZ = point.z,
DirX = dir.x, DirY = dir.y, DirZ = dir.z,
});using ZOA.Networking.Abstractions;
using ZOA.Networking.Unity;
var profile = NetworkSessionBootstrap.ResolveProfile(
SessionBootstrapMode.Dedicated,
hasCompetitiveLicense: gate.CanActivateCompetitive);
var transport = NetworkSessionBootstrap.SelectTransport(profile, preferSteamTransport: true);
if (transport.UsedFallback)
Debug.LogWarning(transport.Reason);
if (!NetworkSessionBootstrap.TryPrepareJoin(
token,
authorityProtocolVersion: NetworkProtocolVersion.Initial,
authorityBuildVersion: Application.version,
authorityProfileId: profile.ProfileId,
authorityIsModded: false,
authorityContentHash: contentHash,
allowModdedMismatch: false,
out var compatibility,
out var reason))
{
// compatibility.State is one of InvalidToken, IncompatibleMajor,
// ClientTooOld, ServerTooOld, BuildVersionMismatch, ProfileMismatch,
// ModStateMismatch, or ContentHashMismatch. reason is player-facing.
ShowJoinFailed(reason);
return;
}using UnityEngine;
using ZOA.Messaging;
using ZOA.Networking.Core;
using ZOA.Networking.Unity.Spawn;
// Lives inside Runtime/Unity/<Backend>/ next to a per-backend asmdef
// carrying defineConstraints for ZOA_NETWORK_<BACKEND>_AVAILABLE and
// ZOA_NETWORK_<BACKEND>_ENABLED. No source-level conditional needed.
internal static class MyBackendBootstrap
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void AutoRegister()
{
// Idempotent: honour an existing registration so a project can
// override the active backend or spawner by hand.
if (NetworkingBackendRegistry.ResolveOrNull(MyBackend.BackendId) != null)
return;
NetworkingBackendRegistry.Register(new MyBackend());
if (!FoundryServiceRegistry.TryResolve<INetworkSpawner>(out var existing) || existing == null)
FoundryServiceRegistry.Register<INetworkSpawner>(new MyNetworkSpawner());
}
}Tooling
Editor tools
Networking Workbench module
Tools/ZOA/Workbench, Networking
The single entry point. Creator tools for the baseline assets, an embedded Multiplayer Setup wizard, a state channel browser, an ownership viewer, a validation runner, and an architecture overview. Every backend row is labelled with its support tier.
Multiplayer Setup wizard
Tools/ZOA/Advanced/Build/Networking/Multiplayer Setup
Guided authoring for either local split-screen or distributed networking. In distributed mode it writes a NetworkingSessionConfig asset at a chosen path and optionally attaches a NetworkingSessionRunner to the active scene. Split-screen mode configures a local multiplayer director through reflection, so the wizard keeps no hard dependency on the player package.
Create NetworkSettings
Tools/ZOA/Advanced/Define/Networking/Create NetworkSettings (Default Path)
Creates the settings asset at Assets/Resources/ZOA/NetworkSettings.asset so NetworkSettings.Instance resolves at runtime.
Apply Foundry Defaults
Tools/ZOA/Advanced/Build/Networking/Apply Foundry Defaults (FishNet + Steam Host)
Sets the FishNet backend, the host-authoritative topology, and the Steam transport preference in one action. IsFoundryDefaultsCompliant reports drift from that policy with a human-readable summary.
Create Default Session Profiles
Tools/ZOA/Advanced/Define/Networking/Create Default Session Profiles
Writes SessionProfileDefinition assets for free host and dedicated play into the Resources profiles folder, so the resolver reads authored assets rather than the built-in fallbacks.
Create Network Prefab Catalog
Tools/ZOA/Advanced/Define/Networking/Create Network Prefab Catalog
Creates the catalog asset that NetworkPrefabPool and backend prefab registration read from.
Create Multiplayer NetworkManager Prefab
Tools/ZOA/Advanced/Build/Networking/Create Multiplayer NetworkManager Prefab
Builds the canonical scene prefab hosting MultiplayerNetworkManagerFacade, which exposes StartHost, StartServerOnly, StartClientOnly, and StopRuntime over the selected lifecycle driver and transport.
Multiplayer build dry-run
Tools/ZOA/Advanced/Validate/Networking/Run Multiplayer Build Dry-Run
Validates the multiplayer build targets declared in MultiplayerBuildTargetCatalog: retail client, dedicated server, server manager, and support console.
FishNet integration row
Tools/ZOA/Foundry Extensions
Reports which of the four gating states the FishNet integration is in and toggles the ENABLED define. This is the only supported way to opt in; editing the define list by hand bypasses the migration logic.
Server Manager tooling
Operator-side classes for a self-hosted dedicated server: ServerInstallConfig for ports, profile, rotation, password, MOTD, and logging with a validation pass; ServerProcessController for launch, stop, restart, and captured stdout; SupportBundleExporter for a bundle the Support Console can read back.
Support Console tooling
Internal-only classes kept out of the retail client: LicenseToolkit for creating, replacing, and revoking signed server licences; SupportBundleReader for inspecting an exported bundle and producing a readable incident report; RepairArtifactGenerator for scoped, signed repair artefacts.
Read this
Notes and caveats
See also