Platformcom.zoa.networking · v0.1.0

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.

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.

The Networking module in the Workbench

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.

The Multiplayer Setup wizard in distributed mode

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.

The in-game network performance HUD

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.

FishNet gating in the Foundry Extensions browser

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

Registering a channel and replicating through itcsharp
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.
}
Ownership is checked on every write. A rejected TryReplicate returns false rather than throwing, so the caller decides whether that is a bug or an expected race.
Starting a session with runtime overridescsharp
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);
}
StartSession is idempotent after the first successful start, so a double-click on a Host button cannot open two sessions.
Publishing and consuming player snapshotscsharp
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));
The two-phase damage flowcsharp
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,
});
Death never travels as its own message. It arrives as the Dead bit in the victim's next snapshot, which keeps one ordering for state and one for events.
Preflighting a joincsharp
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;
}
Auto-registering a new backend adaptercsharp
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());
    }
}
The registry makes the first registered backend active automatically, so a project with one adapter needs no explicit selection.

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