Multiplayer-Ready Performance Architecture Guide

January 24, 2026 · View on GitHub

Core Multiplayer Principles

The Fundamental Challenge

In single-player, you control everything. In multiplayer, you must maintain deterministic simulation across all clients while keeping high performance.

Single-Player:
GPU has all data → Renders immediately → 200 FPS

Multiplayer:
Game state must be synchronized → Validated → Then rendered → ???FPS

Architecture Decision: Client-Server vs Peer-to-Peer vs Hybrid

Option 1: Lockstep (Paradox Style)

How it works: All clients run identical simulation, only share inputs

Client A: "I clicked province 547" → All clients
Client B: "I moved army to 122" → All clients  
Everyone simulates the same result

Pros:

  • Minimal network traffic (only sending commands)
  • No host advantage
  • Works with our GPU architecture

Cons:

  • Slowest player sets the speed
  • Desyncs are catastrophic
  • Pause-heavy gameplay

Option 2: Client-Server (Modern Approach)

How it works: Authoritative server, clients predict and correct

Client: "I want to own province 547" → Server
Server: "Approved, you now own 547" → All clients
Clients: Update texture and render

Pros:

  • No desyncs possible
  • Cheating prevention
  • Speed independent per player

Cons:

  • Higher bandwidth
  • Needs rollback/prediction
  • Server costs

Authoritative server for state, client-side rendering optimizations

Separating Simulation from Presentation

The Key Pattern: Dual-Layer Architecture

// LAYER 1: Authoritative Game State (CPU, synchronized)
public class GameState {
    // Minimal, deterministic data
    struct ProvinceState {
        public ushort ownerID;
        public ushort controllerID;
        public byte development;
        public byte fortLevel;
    }
    
    // Fixed-size, deterministic
    NativeArray<ProvinceState> provinces;
    
    // This is what gets synchronized
    public byte[] Serialize() {
        // Efficient binary serialization
        return provinces.Reinterpret<byte>().ToArray();
    }
}

// LAYER 2: Presentation State (GPU, local)
public class RenderState {
    // GPU textures for rendering
    Texture2D provinceOwners;
    Texture2D provinceColors;
    RenderTexture borders;
    
    // Updated FROM GameState, but can have local additions
    public void UpdateFromGameState(GameState state) {
        // Convert authoritative state to GPU format
        UpdateOwnershipTexture(state.provinces);
        UpdateBorderTexture(state.provinces);
    }
    
    // Local-only visual effects
    public void AddLocalEffects() {
        // Hovering, animations, etc. - not synchronized
        AddHoverEffect(localMousePosition);
        AnimateBorderPulse(selectedProvince);
    }
}

Network-Optimized Data Structures

Province Updates - Differential Synchronization

public class ProvinceNetworkUpdate {
    // Don't send all 10,000 provinces every tick!
    // Only send changes
    
    public struct ProvinceDelta {
        public ushort provinceID;
        public byte fieldMask;  // Which fields changed
        public ushort newValue;  // The new value
    }
    
    // Typical frame: 0-10 provinces change
    // Network data: 10 × 4 bytes = 40 bytes
    // Instead of: 10,000 × 8 bytes = 80,000 bytes!
    
    public void SendUpdate(List<ProvinceDelta> deltas) {
        // Pack into smallest possible packet
        using (var writer = new NetworkWriter()) {
            writer.WriteByte((byte)deltas.Count);
            foreach (var delta in deltas) {
                writer.WriteUShort(delta.provinceID);
                writer.WriteByte(delta.fieldMask);
                writer.WriteUShort(delta.newValue);
            }
            Send(writer.ToArray());  // ~40-100 bytes typically
        }
    }
}

Texture Updates - Client-Side Prediction

public class PredictiveRenderer {
    // Immediately update visual, correct later if needed
    
    public void OnLocalAction(int provinceID, int newOwner) {
        // 1. Immediately update GPU texture (instant feedback)
        UpdateProvinceTexture(provinceID, newOwner);
        
        // 2. Send to server
        SendOwnerChangeRequest(provinceID, newOwner);
        
        // 3. Store prediction
        predictions.Add(new Prediction {
            provinceID = provinceID,
            predictedOwner = newOwner,
            timestamp = NetworkTime.time
        });
    }
    
    public void OnServerResponse(int provinceID, int actualOwner) {
        if (actualOwner != GetPredictedOwner(provinceID)) {
            // Prediction was wrong, correct it
            UpdateProvinceTexture(provinceID, actualOwner);
            // Maybe add visual indicator of correction
        }
        predictions.Remove(provinceID);
    }
}

Multiplayer-Specific Optimizations

1. Temporal Coherence Across Network

public class NetworkTemporalCache {
    // Cache province states to detect changes
    ProvinceState[] lastSentState;
    ProvinceState[] currentState;
    
    public List<int> GetChangedProvinces() {
        var changed = new List<int>();
        
        // Parallel comparison using Burst
        var job = new CompareStatesJob {
            previous = lastSentState,
            current = currentState,
            changed = changed
        };
        job.Schedule(10000, 64).Complete();
        
        return changed;
    }
}

2. LOD for Network Updates

public class NetworkLOD {
    // Not all provinces need same update rate
    
    enum UpdatePriority {
        Critical = 1,    // Every tick (combat, player provinces)
        Important = 5,   // Every 5 ticks (nearby provinces)
        Background = 20  // Every 20 ticks (far away provinces)
    }
    
    public UpdatePriority GetProvincePriority(int provinceID) {
        if (IsPlayerProvince(provinceID)) return UpdatePriority.Critical;
        if (IsVisibleToPlayer(provinceID)) return UpdatePriority.Important;
        return UpdatePriority.Background;
    }
}

3. Batched GPU Updates

public class BatchedTextureUpdater {
    // Collect all network updates, apply once per frame
    Queue<ProvinceUpdate> pendingUpdates = new();
    
    void OnNetworkUpdate(ProvinceUpdate update) {
        pendingUpdates.Enqueue(update);
        // DON'T apply immediately
    }
    
    void LateUpdate() {
        if (pendingUpdates.Count > 0) {
            // Apply all at once
            var pixels = provinceTexture.GetPixels32();
            
            while (pendingUpdates.Count > 0) {
                var update = pendingUpdates.Dequeue();
                ApplyToPixelArray(pixels, update);
            }
            
            provinceTexture.SetPixels32(pixels);
            provinceTexture.Apply();  // Single GPU upload
        }
    }
}

Determinism Requirements

What Must Be Deterministic (Synchronized)

public class DeterministicState {
    // These MUST be identical across all clients
    FixedPoint64 provinceWealth;  // Not float!
    int32 provinceDevelopment;    // Exact integers
    uint16 ownerID;              // No ambiguity
    
    // Use fixed-point math, not floating point
    FixedPoint64 CalculateTax(FixedPoint64 baseTax) {
        return baseTax * FixedPoint64.FromFloat(1.1f);
    }
}

What Can Be Non-Deterministic (Local)

public class LocalOnlyState {
    // These can differ between clients
    float animationTime;         // Visual only
    Color highlightColor;        // UI preference
    bool showingTooltip;         // UI state
    ParticleSystem effects;      // Visual flair
    
    // GPU textures for rendering (derived from deterministic state)
    Texture2D visualRepresentation;
}

Network Architecture Patterns

Pattern 1: Command Pattern for Province Changes

public interface IProvinceCommand {
    void Execute(GameState state);
    byte[] Serialize();
}

public class ChangeOwnerCommand : IProvinceCommand {
    ushort provinceID;
    ushort newOwnerID;

    public void Execute(GameState state) {
        state.provinces[provinceID].ownerID = newOwnerID;
    }

    public byte[] Serialize() {
        // 5 bytes: [CommandType(1)][ProvinceID(2)][OwnerID(2)]
        return new byte[] {
            (byte)CommandType.ChangeOwner,
            (byte)(provinceID & 0xFF),
            (byte)(provinceID >> 8),
            (byte)(newOwnerID & 0xFF),
            (byte)(newOwnerID >> 8)
        };
    }
}

See Save/Load Architecture - command pattern enables both multiplayer AND save/replay with the same code.

Pattern 2: Ring Buffer for Rollback

public class RollbackBuffer {
    const int ROLLBACK_FRAMES = 30;  // 0.5 seconds at 60fps
    
    GameState[] stateHistory = new GameState[ROLLBACK_FRAMES];
    int currentFrame = 0;
    
    public void SaveState(GameState state) {
        stateHistory[currentFrame % ROLLBACK_FRAMES] = state.Clone();
        currentFrame++;
    }
    
    public void Rollback(int toFrame) {
        // Restore state
        var targetState = stateHistory[toFrame % ROLLBACK_FRAMES];
        currentState = targetState.Clone();
        
        // Re-apply commands from that point
        for (int f = toFrame + 1; f <= currentFrame; f++) {
            ApplyCommandsForFrame(f);
        }
        
        // Update GPU textures once
        UpdateAllTextures(currentState);
    }
}

Pattern 3: Interpolation for Smooth Visuals

public class InterpolatedRenderer {
    // Server updates at 10Hz, render at 144Hz
    
    ProvinceState previousState;
    ProvinceState targetState;
    float interpolationTime;
    
    void OnServerUpdate(ProvinceState newState) {
        previousState = currentState;
        targetState = newState;
        interpolationTime = 0;
    }
    
    void Update() {
        interpolationTime += Time.deltaTime * 10f;  // 10Hz server rate
        interpolationTime = Mathf.Min(interpolationTime, 1f);
        
        // Smooth visual interpolation
        var interpolated = ProvinceState.Lerp(
            previousState, 
            targetState, 
            interpolationTime
        );
        
        UpdateGPUTextures(interpolated);
    }
}

Performance Considerations for Multiplayer

Memory Layout for Network Efficiency

// BAD: Scattered, hard to serialize
public class Province {
    string name;           // Variable size
    List<int> neighbors;   // Variable size
    Dictionary<string, float> modifiers;  // Variable size
}

// GOOD: Fixed size, contiguous, efficient
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ProvinceNetworkData {
    public ushort id;
    public ushort owner;
    public byte development;
    public byte terrain;
    // Fixed 6 bytes, easy to serialize
}

Note: This network-optimized memory layout aligns with the hot/cold data separation described in Performance Architecture. The 8-byte ProvinceState struct represents the "hot data" that needs synchronization, while cold data remains local.

Bandwidth Optimization

public class BandwidthOptimizer {
    // Typical Paradox game: ~10-50 KB/s per client
    // Our target: <5 KB/s
    
    // Technique 1: Delta compression
    byte[] CompressDelta(ProvinceState[] oldState, ProvinceState[] newState) {
        using (var stream = new MemoryStream())
        using (var writer = new BinaryWriter(stream)) {
            for (int i = 0; i < oldState.Length; i++) {
                if (!oldState[i].Equals(newState[i])) {
                    writer.Write((ushort)i);  // Province ID
                    writer.Write(newState[i].Pack());  // New state
                }
            }
            return stream.ToArray();
        }
    }
    
    // Technique 2: Bit packing
    uint PackProvinceFlags(Province p) {
        uint packed = 0;
        packed |= (uint)(p.isCoastal ? 1 : 0) << 0;
        packed |= (uint)(p.hasFort ? 1 : 0) << 1;
        packed |= (uint)(p.isOccupied ? 1 : 0) << 2;
        // ... 29 more boolean flags in 4 bytes
        return packed;
    }
}

Testing Multiplayer Performance

public class MultiplayerStressTest {
    [Test]
    public void TestBandwidthUnder5KB() {
        // Simulate 1 hour of gameplay
        var updates = SimulateGameplay(3600);
        var bandwidth = CalculateBandwidth(updates);
        Assert.Less(bandwidth, 5000);  // Under 5 KB/s
    }
    
    [Test]
    public void TestDeterminism() {
        var client1 = new GameState(seed: 12345);
        var client2 = new GameState(seed: 12345);
        
        // Apply same commands
        var commands = GenerateCommands(100);
        foreach (var cmd in commands) {
            client1.Execute(cmd);
            client2.Execute(cmd);
        }
        
        // States must be identical
        Assert.AreEqual(client1.Hash(), client2.Hash());
    }
    
    [Test]
    public void TestRollbackPerformance() {
        // Should handle 30 frame rollback in <16ms
        var stopwatch = Stopwatch.StartNew();
        RollbackFrames(30);
        stopwatch.Stop();
        Assert.Less(stopwatch.ElapsedMilliseconds, 16);
    }
}

Architecture Decision Matrix

AspectSingle-Player OnlyMP-Ready (Lockstep)MP-Ready (Client-Server)
GPU TexturesDirect updateMust sync firstPredict + correct
Province DataAll on GPUGPU + CPU mirrorGPU + CPU mirror
Memory LayoutOptimized for GPUFixed-size for networkFixed-size for network
Update FrequencyEvery frameFixed tick rateVariable with interpolation
Bandwidth0~5 KB/s~10 KB/s
ComplexityLowMediumHigh
Performance200+ FPS144+ FPS144+ FPS
CheatingN/APossibleProtected
  1. Start with MP-ready architecture even if you don't implement multiplayer immediately
  2. Separate simulation and presentation layers from day one
  3. Use fixed-size data structures throughout
  4. Keep authoritative state minimal (<10 bytes per province)
  5. Let GPU handle all visual representation (not synchronized)
  6. Design for 10Hz network updates with 144Hz visual interpolation

This architecture gives you:

  • Single-player: 200+ FPS
  • Multiplayer: 144+ FPS with <5KB/s bandwidth
  • Flexibility to add multiplayer later without rewriting

Transport Layer Architecture (Implementation Plan)

Session Model: Player-Hosted (Paradox Style)

One player acts as host (runs server + client), others connect as clients. This matches the multiplayer model used in EU4, CK3, HOI4, and Victoria 3.

┌─────────────────────────────────────────────────────────┐
│                      HOST PLAYER                        │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐ │
│  │   Client    │◄──►│   Server    │◄──►│  Transport  │ │
│  │  (local)    │    │   Logic     │    │   Layer     │ │
│  └─────────────┘    └─────────────┘    └──────┬──────┘ │
└───────────────────────────────────────────────┼────────┘

                    ┌───────────────────────────┼───────────────────────────┐
                    │                           │                           │
            ┌───────▼───────┐           ┌───────▼───────┐           ┌───────▼───────┐
            │   CLIENT 1    │           │   CLIENT 2    │           │   CLIENT N    │
            │  ┌─────────┐  │           │  ┌─────────┐  │           │  ┌─────────┐  │
            │  │Transport│  │           │  │Transport│  │           │  │Transport│  │
            │  └────┬────┘  │           │  └────┬────┘  │           │  └────┬────┘  │
            │  ┌────▼────┐  │           │  ┌────▼────┐  │           │  ┌────▼────┐  │
            │  │ Client  │  │           │  │ Client  │  │           │  │ Client  │  │
            │  │  Logic  │  │           │  │  Logic  │  │           │  │  Logic  │  │
            │  └─────────┘  │           │  └─────────┘  │           │  └─────────┘  │
            └───────────────┘           └───────────────┘           └───────────────┘

Transport Abstraction Layer

Abstract transport to support multiple backends without changing game logic:

/// <summary>
/// Transport-agnostic network interface.
/// Implementations handle the actual network I/O.
/// </summary>
public interface INetworkTransport : IDisposable
{
    // Connection state
    bool IsRunning { get; }
    bool IsHost { get; }

    // Events
    event Action<int> OnClientConnected;      // peerId
    event Action<int> OnClientDisconnected;   // peerId
    event Action<int, byte[]> OnDataReceived; // peerId, data

    // Host operations
    void StartHost(int port);
    void StopHost();

    // Client operations
    void Connect(string address, int port);
    void Disconnect();

    // Data transmission
    void Send(int peerId, byte[] data, DeliveryMethod method);
    void SendToAll(byte[] data, DeliveryMethod method);
    void SendToAllExcept(int excludePeerId, byte[] data, DeliveryMethod method);

    // Must be called each frame to process incoming data
    void Poll();
}

public enum DeliveryMethod
{
    Unreliable,           // Fire and forget (UDP)
    ReliableUnordered,    // Guaranteed delivery, any order
    ReliableOrdered       // Guaranteed delivery, in order (commands)
}

Transport Implementations

INetworkTransport
├── DirectTransport      (Development/LAN - Unity Transport Package)
└── SteamTransport       (Release - Steamworks.NET / SteamNetworkingSockets)

DirectTransport (Development)

Uses Unity Transport Package for direct IP:port connections.

public class DirectTransport : INetworkTransport
{
    private NetworkDriver driver;
    private NativeList<NetworkConnection> connections;

    // For development: players connect via IP address
    // Requires port forwarding for internet play

    public void StartHost(int port)
    {
        var endpoint = NetworkEndpoint.AnyIpv4.WithPort((ushort)port);
        driver.Bind(endpoint);
        driver.Listen();
    }

    public void Connect(string address, int port)
    {
        var endpoint = NetworkEndpoint.Parse(address, (ushort)port);
        driver.Connect(endpoint);
    }
}

Pros:

  • Simple, no external dependencies beyond Unity
  • Burst-compatible (NativeArray-based)
  • Good for LAN and development
  • No account system required

Cons:

  • Requires port forwarding for internet play
  • No NAT punchthrough

SteamTransport (Release)

Uses Steamworks.NET with SteamNetworkingSockets for Steam-based connections.

public class SteamTransport : INetworkTransport
{
    // Players connect via Steam ID, not IP
    // Steam handles NAT punchthrough via relay servers
    // Integrates with Steam lobbies for session discovery

    public void Connect(string steamId, int port)
    {
        var identity = new SteamNetworkingIdentity();
        identity.SetSteamID(new CSteamID(ulong.Parse(steamId)));

        connection = SteamNetworkingSockets.ConnectP2P(
            ref identity,
            port,
            0,
            null
        );
    }

    // Steam lobby integration for "Join Game" via friends list
    public void CreateLobby(int maxPlayers)
    {
        SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypeFriendsOnly, maxPlayers);
    }
}

Pros:

  • Automatic NAT punchthrough (Steam relay)
  • No port forwarding needed
  • Steam friend integration (invite, join via profile)
  • Steam authentication (anti-cheat foundation)
  • Free for Steam developers

Cons:

  • Requires Steam running
  • Steam developer account needed
  • Players must own game on Steam

NetworkManager Integration

The NetworkManager sits between game logic and transport:

public class NetworkManager : IDisposable
{
    private INetworkTransport transport;
    private CommandProcessor commandProcessor;
    private CommandSerializer commandSerializer;

    // Peer tracking
    private Dictionary<int, NetworkPeer> peers = new();
    private int localPeerId;

    public bool IsHost => transport?.IsHost ?? false;
    public bool IsConnected => transport?.IsRunning ?? false;

    public void Initialize(INetworkTransport transport)
    {
        this.transport = transport;
        transport.OnClientConnected += HandleClientConnected;
        transport.OnClientDisconnected += HandleClientDisconnected;
        transport.OnDataReceived += HandleDataReceived;
    }

    // Host: receive command from client, validate, broadcast to all
    private void HandleDataReceived(int peerId, byte[] data)
    {
        var messageType = (NetworkMessageType)data[0];

        switch (messageType)
        {
            case NetworkMessageType.CommandBatch:
                HandleCommandBatch(peerId, data);
                break;
            case NetworkMessageType.StateSync:
                HandleStateSync(data);
                break;
            case NetworkMessageType.Heartbeat:
                HandleHeartbeat(peerId);
                break;
        }
    }

    private void HandleCommandBatch(int peerId, byte[] data)
    {
        var commands = commandSerializer.DeserializeBatch(data.AsSpan(1));

        if (IsHost)
        {
            // Validate and execute on authoritative state
            foreach (var cmd in commands)
            {
                if (cmd.Validate(gameState))
                {
                    commandProcessor.Execute(cmd);
                    // Broadcast confirmed command to all clients
                    BroadcastCommand(cmd);
                }
            }
        }
        else
        {
            // Client: apply confirmed commands from host
            foreach (var cmd in commands)
            {
                commandProcessor.Execute(cmd);
            }
        }
    }

    // Client: send local command to host
    public void SendCommand(INetworkCommand command)
    {
        var data = commandSerializer.Serialize(command);

        if (IsHost)
        {
            // Execute locally and broadcast
            commandProcessor.Execute(command);
            transport.SendToAll(data, DeliveryMethod.ReliableOrdered);
        }
        else
        {
            // Send to host for validation
            transport.Send(hostPeerId, data, DeliveryMethod.ReliableOrdered);
            // Optionally: add to prediction buffer
            commandBuffer.AddPredicted(command);
        }
    }
}

Network Message Protocol

public enum NetworkMessageType : byte
{
    // Connection
    Handshake = 0x01,
    HandshakeResponse = 0x02,
    Disconnect = 0x03,
    Heartbeat = 0x04,

    // Game state
    CommandBatch = 0x10,        // Batch of commands (uses existing CommandSerializer)
    StateSync = 0x11,           // Full state for late joiners
    StateDelta = 0x12,          // Differential state update

    // Session management
    PlayerJoined = 0x20,
    PlayerLeft = 0x21,
    GameSpeedChange = 0x22,
    PauseRequest = 0x23,

    // Synchronization
    ChecksumRequest = 0x30,     // Request state checksum
    ChecksumResponse = 0x31,    // Return checksum for desync detection
    DesyncRecovery = 0x32       // Full state resync after desync
}

// Message header (all messages start with this)
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct NetworkMessageHeader
{
    public NetworkMessageType Type;  // 1 byte
    public uint Tick;                // 4 bytes - game tick this message relates to
    public ushort PayloadSize;       // 2 bytes
    // Total: 7 bytes header
}

Late Joiner Synchronization

When a player joins mid-game, they need the full state:

public class LateJoinHandler
{
    public void HandleNewClient(int peerId)
    {
        // 1. Pause command acceptance from new client
        peers[peerId].State = PeerState.Synchronizing;

        // 2. Serialize full game state
        var stateData = gameState.SerializeFull();

        // 3. Send in chunks (state may be large)
        const int CHUNK_SIZE = 32 * 1024; // 32KB chunks
        for (int i = 0; i < stateData.Length; i += CHUNK_SIZE)
        {
            var chunk = stateData.AsSpan(i, Math.Min(CHUNK_SIZE, stateData.Length - i));
            SendStateChunk(peerId, i, chunk);
        }

        // 4. Send current tick number
        SendSyncComplete(peerId, currentTick);

        // 5. Client loads state, begins accepting commands
        peers[peerId].State = PeerState.Connected;
    }
}

Desync Detection & Automatic Recovery

Philosophy: Unlike Paradox games that treat desyncs as fatal errors requiring a rehost, we expect desyncs to happen occasionally and recover automatically. This is a key differentiator.

Why Desyncs Happen

Even with deterministic design, desyncs can occur:

  • Uninitialized memory in a new struct field
  • A Dictionary<> iteration sneaking into gameplay code
  • Floating point math in a forgotten calculation
  • Race condition in a Burst job
  • Bug in command serialization (different state after save/load round-trip)

The goal isn't to prevent all desyncs (impossible) but to detect and recover seamlessly.

Detection: Periodic Checksum Verification

public class DesyncDetector
{
    // Check every N ticks (balance between responsiveness and bandwidth)
    private const int CHECKSUM_INTERVAL_TICKS = 60;  // ~1 second at 60 ticks/sec

    // Track recent checksums for debugging
    private RingBuffer<(uint tick, uint checksum)> checksumHistory = new(256);

    public void OnTick(uint currentTick)
    {
        if (currentTick % CHECKSUM_INTERVAL_TICKS != 0)
            return;

        uint checksum = gameState.ComputeChecksum();
        checksumHistory.Add((currentTick, checksum));

        if (networkManager.IsHost)
        {
            // Host stores authoritative checksum
            authoritativeChecksums[currentTick] = checksum;

            // Broadcast to clients for comparison
            BroadcastChecksum(currentTick, checksum);
        }
        else
        {
            // Client sends checksum to host
            SendChecksumToHost(currentTick, checksum);
        }
    }

    // Host receives client checksum
    public void OnClientChecksum(int peerId, uint tick, uint clientChecksum)
    {
        if (!authoritativeChecksums.TryGetValue(tick, out uint hostChecksum))
            return;  // Too old, already pruned

        if (clientChecksum != hostChecksum)
        {
            Log.Warning($"Desync detected! Peer {peerId} at tick {tick}. " +
                        $"Host: {hostChecksum:X8}, Client: {clientChecksum:X8}");

            // Trigger recovery
            desyncRecovery.RecoverClient(peerId, tick);
        }
    }

    // Client receives host checksum
    public void OnHostChecksum(uint tick, uint hostChecksum)
    {
        var localEntry = checksumHistory.Find(e => e.tick == tick);
        if (localEntry == default)
            return;  // We don't have this tick anymore

        if (localEntry.checksum != hostChecksum)
        {
            Log.Warning($"Local desync detected at tick {tick}. " +
                        $"Host: {hostChecksum:X8}, Local: {localEntry.checksum:X8}");

            // Request recovery from host
            RequestStateResync();
        }
    }
}

Recovery: Automatic State Resync

Reuses the same mechanism as late-joiner sync:

public class DesyncRecovery
{
    public event Action OnDesyncDetected;      // For UI notification
    public event Action OnResyncComplete;      // For UI notification

    // Host-side: resync a desynced client
    public void RecoverClient(int peerId, uint desyncTick)
    {
        var peer = peers[peerId];

        // 1. Mark client as resyncing (pause their input temporarily)
        peer.State = PeerState.Resyncing;
        SendResyncStarting(peerId);

        // 2. Use existing late-join infrastructure
        lateJoinHandler.HandleNewClient(peerId);

        // 3. Log for debugging (helps identify patterns)
        LogDesyncEvent(peerId, desyncTick);
    }

    // Client-side: receive and apply resynced state
    public void OnResyncStateReceived(byte[] stateData, uint hostTick)
    {
        OnDesyncDetected?.Invoke();  // Show "Resyncing..." UI

        // 1. Clear local state
        gameState.Clear();

        // 2. Load authoritative state from host
        gameState.DeserializeFull(stateData);

        // 3. Update all visuals
        renderState.ForceFullUpdate(gameState);

        // 4. Resume normal operation
        currentTick = hostTick;

        OnResyncComplete?.Invoke();  // Hide "Resyncing..." UI

        Log.Info($"Resync complete. Now at tick {hostTick}");
    }
}

Player Experience

┌─────────────────────────────────────────────────────────────────┐
│                     DESYNC RECOVERY TIMELINE                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Normal play     Desync          Recovery        Back to normal │
│  ───────────────►│◄─────────────►│◄─────────────►───────────── │
│                  │               │               │              │
│  Players         │ Brief pause   │ "Resyncing"   │ Game         │
│  playing         │ (< 100ms)     │ text shown    │ continues    │
│                  │               │ (1-3 sec)     │              │
│                                                                 │
│  Total disruption: 1-3 seconds (vs Paradox: 5+ minutes rehost) │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

UI Treatment:

public class DesyncUI : MonoBehaviour
{
    [SerializeField] private GameObject resyncingPanel;
    [SerializeField] private TMP_Text resyncingText;

    void Start()
    {
        desyncRecovery.OnDesyncDetected += () => {
            resyncingPanel.SetActive(true);
            resyncingText.text = "Resyncing...";
        };

        desyncRecovery.OnResyncComplete += () => {
            resyncingPanel.SetActive(false);
        };
    }
}

What players see:

  • Small, non-intrusive notification: "Resyncing..." (not a scary error dialog)
  • Game pauses briefly (1-3 seconds)
  • Game continues normally
  • No rehost, no lobby, no lost progress

Debugging: Finding Desync Causes

In development builds, capture detailed state for comparison:

public class DesyncDebugger
{
    [Conditional("DEBUG")]
    public void OnDesyncDetected(uint tick, uint localChecksum, uint hostChecksum)
    {
        // Dump full state to file for comparison
        var stateDump = new DesyncDump {
            Tick = tick,
            LocalChecksum = localChecksum,
            HostChecksum = hostChecksum,
            ProvinceStates = gameState.Provinces.ToArray(),
            CountryStates = gameState.Countries.ToArray(),
            UnitStates = gameState.Units.ToArray(),
            // Add other relevant state...
        };

        string path = $"Logs/desync_{tick}_{DateTime.Now:yyyyMMdd_HHmmss}.json";
        File.WriteAllText(path, JsonUtility.ToJson(stateDump, prettyPrint: true));

        Log.Error($"Desync dump written to {path}");
    }

    // Tool to compare two desync dumps
    public static void CompareDesyncDumps(string hostDump, string clientDump)
    {
        var host = JsonUtility.FromJson<DesyncDump>(File.ReadAllText(hostDump));
        var client = JsonUtility.FromJson<DesyncDump>(File.ReadAllText(clientDump));

        // Find first difference
        for (int i = 0; i < host.ProvinceStates.Length; i++)
        {
            if (!host.ProvinceStates[i].Equals(client.ProvinceStates[i]))
            {
                Log.Error($"Province {i} differs: Host={host.ProvinceStates[i]}, Client={client.ProvinceStates[i]}");
                return;
            }
        }
        // Continue for other state...
    }
}

Checksum Implementation

Fast, deterministic checksum using existing data layout:

public partial class GameState
{
    public uint ComputeChecksum()
    {
        uint hash = 2166136261;  // FNV-1a offset basis

        // Province state (already contiguous NativeArray)
        hash = HashNativeArray(provinces, hash);

        // Country state
        hash = HashNativeArray(countries, hash);

        // Unit state
        hash = HashNativeArray(units, hash);

        // Current tick (ensures time sync)
        hash = HashValue(currentTick, hash);

        return hash;
    }

    private static uint HashNativeArray<T>(NativeArray<T> array, uint hash)
        where T : unmanaged
    {
        var bytes = array.Reinterpret<byte>(UnsafeUtility.SizeOf<T>());
        for (int i = 0; i < bytes.Length; i++)
        {
            hash ^= bytes[i];
            hash *= 16777619;  // FNV-1a prime
        }
        return hash;
    }

    private static uint HashValue<T>(T value, uint hash) where T : unmanaged
    {
        byte* ptr = (byte*)&value;
        for (int i = 0; i < sizeof(T); i++)
        {
            hash ^= ptr[i];
            hash *= 16777619;
        }
        return hash;
    }
}

Comparison: Archon vs Paradox

AspectParadox GamesArchon Engine
Desync detectionManual (player notices) or lateAutomatic every ~1 second
Recovery methodSave, quit, rehost, rejoinAutomatic state resync
Player disruption5-10 minutes, kills momentum1-3 seconds, barely noticeable
Data for debugging"Checksum mismatch" (useless)Full state dumps with diff tool
Philosophy"Shouldn't happen""Will happen, handle gracefully"

This approach turns a game-breaking issue into a minor hiccup.

Implementation Phases

Phase 1: Core Transport (Development) ✅ COMPLETE

  • Implement INetworkTransport interface
  • Implement DirectTransport using Unity Transport Package
  • Basic NetworkManager with host/client modes
  • Wire up to existing CommandSerializer
  • Simple lobby UI (host game / join by IP)

Phase 2: Game Integration ✅ COMPLETE

  • Player assignment (which client controls which country)
  • Game speed synchronization (host controls speed) - NetworkTimeSync
  • Pause/resume synchronization
  • Country selection in lobby with visual feedback
  • Ready/Start game flow
  • GameCommandProcessor for GAME layer command sync
  • AI runs only on host (NetworkInitializer.IsHost)

Phase 2.5: Desync Detection & Recovery (Key Differentiator)

  • DesyncDetector - checksum comparison logic (infrastructure ready)
  • DesyncRecovery - automatic state resync (infrastructure ready)
  • Periodic checksum broadcasting (every N ticks)
  • Resync UI ("Resyncing..." notification)
  • Debug: State dump on desync for comparison
  • Debug: Diff tool to find first divergence

Phase 3: Steam Integration

  • Implement SteamTransport using Steamworks.NET
  • Steam lobby creation/discovery
  • Join via Steam friends list
  • Steam authentication

Phase 4: Polish

  • Reconnection handling
  • Host migration (optional, complex)
  • Network statistics UI (ping, bandwidth)
  • Improved desync recovery

File Structure (Implemented)

Scripts/
├── Core/
│   ├── Commands/
│   │   └── GameCommandProcessor.cs     # GAME layer command sync (client→host→broadcast)
│   └── Network/
│       └── INetworkBridge.cs           # Interface for network integration

├── Network/                            # ENGINE network layer
│   ├── INetworkTransport.cs            # Transport interface
│   ├── DirectTransport.cs              # Unity Transport implementation
│   ├── NetworkManager.cs               # High-level network coordinator
│   ├── NetworkBridge.cs                # Implements INetworkBridge
│   ├── NetworkPeer.cs                  # Per-connection state
│   ├── NetworkMessages.cs              # Message types and headers
│   ├── NetworkTimeSync.cs              # Game time synchronization
│   ├── LateJoinHandler.cs              # State sync for joining players
│   ├── DesyncDetector.cs               # Periodic checksum verification
│   └── DesyncRecovery.cs               # Automatic state resync

└── StarterKit/                         # GAME layer
    ├── Network/
    │   ├── NetworkInitializer.cs       # Setup host/client, lobby state
    │   └── LobbyUI.cs                  # Host/Join/Ready UI
    └── Commands/                       # All commands network-synced
        ├── CreateUnitCommand.cs        # With explicit CountryId
        ├── DisbandUnitCommand.cs
        ├── ConstructBuildingCommand.cs
        ├── ColonizeCommand.cs
        └── QueueUnitMovementCommand.cs # Custom path serialization

Key Implementation Details

Two Command Systems:

  • CommandProcessor - ENGINE layer (IProvinceCommand)
  • GameCommandProcessor - GAME layer (ICommand) with network sync

Command Flow:

Client UI → Command → GameCommandProcessor.SubmitCommand()
    ↓ (if client)
Send to Host via NetworkBridge
    ↓ (host validates & executes)
Broadcast to all clients

All clients execute identically

Critical Rules Enforced:

  1. Commands include explicit CountryId (never use playerState.PlayerCountryId)
  2. AI runs only on host (NetworkInitializer.IsHost)
  3. System methods take explicit countryId parameter
  4. Commands auto-registered via reflection (sorted alphabetically for determinism)