Skip to main content
Nitrox uses a custom UDP-based networking layer built on LiteNetLib for fast, reliable multiplayer communication. The system is designed for low-latency synchronization of game state between the server and multiple clients.

Network Architecture

Transport Layer: UDP with LiteNetLib

Nitrox uses UDP instead of TCP for performance reasons:
  • Lower Latency: No TCP handshake overhead
  • Unreliable Delivery: Optional for non-critical updates (player positions)
  • Ordered Delivery: Available when needed (building construction)
  • Reliable Delivery: Available for critical events (inventory changes)
// NitroxClient/Communication/NetworkingLayer/LiteNetLib/LiteNetLibClient.cs:50
client = new NetManager(listener)
{
    UpdateTime = 15,  // Poll network every 15ms
    ChannelsCount = (byte)typeof(Packet.UdpChannelId).GetEnumValues().Length,
    IPv6Enabled = true,
    DisconnectTimeout = 300000  // 5 min timeout in DEBUG
};
The network is polled every game tick via PollEvents() to process incoming packets and maintain connection state.

Client-Server Model

The server acts as the authoritative source of truth:
  • Clients send actions to the server
  • Server validates and applies changes to world state
  • Server broadcasts updates to all connected clients
  • Clients apply updates from server, never directly from other clients

Packet System

Base Packet Class

All packets inherit from the abstract Packet class:
// Nitrox.Model/Packets/Packet.cs:14
[Serializable]
public abstract class Packet
{
    [IgnoredMember]
    public NitroxDeliveryMethod.DeliveryMethod DeliveryMethod { get; protected set; } 
        = NitroxDeliveryMethod.DeliveryMethod.RELIABLE_ORDERED;

    [IgnoredMember]
    public UdpChannelId UdpChannel { get; protected set; } = UdpChannelId.DEFAULT;
    
    public enum UdpChannelId : byte
    {
        DEFAULT = 0,
        MOVEMENTS = 1,  // Separate channel for position updates
    }

    public byte[] Serialize()
    {
        return BinaryConverter.Serialize(new Wrapper(this));
    }

    public static Packet Deserialize(byte[] data)
    {
        return BinaryConverter.Deserialize<Wrapper>(data).Packet;
    }
}

Delivery Methods

Nitrox supports multiple delivery guarantees:
// Nitrox.Model/Networking/NitroxDeliveryMethod.cs:6
public enum DeliveryMethod : byte
{
    /// <summary>
    /// Unreliable but in-order. Drops old packets if newer ones arrive.
    /// Use for: Player movement, rotation updates
    /// </summary>
    UNRELIABLE_SEQUENCED,
    
    /// <summary>
    /// Guaranteed delivery, any order.
    /// Use for: Independent events
    /// </summary>
    RELIABLE_UNORDERED,
    
    /// <summary>
    /// Guaranteed delivery, in-order.
    /// Use for: Most game events (default)
    /// </summary>
    RELIABLE_ORDERED,
    
    /// <summary>
    /// Guaranteed delivery, only latest packet matters.
    /// Use for: State synchronization
    /// </summary>
    RELIABLE_ORDERED_LAST,
}
Choose the appropriate delivery method for your packet type. Using RELIABLE_ORDERED for high-frequency updates (like movement) can cause network congestion.

Creating a Packet

Packets are simple serializable data classes:
// Nitrox.Model.Subnautica/Packets/ChatMessage.cs:7
[Serializable]
public class ChatMessage : Packet
{
    public ushort PlayerId { get; }
    public string Text { get; }
    public const ushort SERVER_ID = ushort.MaxValue;

    public ChatMessage(ushort playerId, string text)
    {
        PlayerId = playerId;
        Text = text;
        
        // Chat messages are important but order matters
        DeliveryMethod = NitroxDeliveryMethod.DeliveryMethod.RELIABLE_ORDERED;
        UdpChannel = UdpChannelId.DEFAULT;
    }
}
Packet naming conventions:
  • Use descriptive names: PieceDeconstructed, PlayerMovement, ChatMessage
  • Past tense for events that already happened
  • Present tense for commands: BuildingResyncRequest

Serialization with BinaryPack

Packets are serialized using BinaryPack for efficiency:

Automatic Serialization

BinaryPack automatically handles:
  • Primitive types (int, float, string, etc.)
  • Collections (List, Dictionary, Array)
  • Nested objects
  • Inheritance hierarchies

Union Types

Polymorphic packet types are registered as unions:
// Nitrox.Model/Packets/Packet.cs:40
static IEnumerable<Type> FindUnionBaseTypes() => FindTypesInModelAssemblies()
    .Where(t => t.IsAbstract && !t.IsSealed && (!t.BaseType?.IsAbstract ?? true));

foreach (Type type in FindUnionBaseTypes())
{
    BinaryConverter.RegisterUnion(type, 
        FindTypesInModelAssemblies()
            .Where(t => type.IsAssignableFrom(t) && !t.IsAbstract)
            .OrderByDescending(t => /* inheritance depth */);
}
This allows deserializing abstract packet types to their concrete implementations.
Packet serialization is initialized once at startup via Packet.InitSerializer(). All packet types in Nitrox.Model and Nitrox.Model.Subnautica assemblies are automatically discovered.

Client-Side Networking

Connecting to Server

// NitroxClient/Communication/NetworkingLayer/LiteNetLib/LiteNetLibClient.cs:61
public async Task StartAsync(string ipAddress, int serverPort)
{
    Log.Info("Initializing LiteNetLibClient...");

    await Task.Run(() =>
    {
        client.Start();
        client.Connect(ipAddress, serverPort, "nitrox");
    }).ConfigureAwait(false);

    connectedEvent.WaitOne(2000);  // Wait up to 2s for connection
    connectedEvent.Reset();
}

Sending Packets

// NitroxClient/Communication/NetworkingLayer/LiteNetLib/LiteNetLibClient.cs:77
public void Send(Packet packet)
{
    byte[] packetData = packet.Serialize();
    dataWriter.Reset();
    dataWriter.Put(packetData.Length);  // Length prefix
    dataWriter.Put(packetData);         // Packet data

    networkDebugger?.PacketSent(packet, dataWriter.Length);
    client.SendToAll(dataWriter, 
                     (byte)packet.UdpChannel, 
                     NitroxDeliveryMethod.ToLiteNetLib(packet.DeliveryMethod));
}
Usage in patches:
Resolve<IPacketSender>().Send(new PieceDeconstructed(
    baseId, pieceId, cachedPieceIdentifier, ghostEntity, baseData, operationId
));

Receiving Packets

// NitroxClient/Communication/NetworkingLayer/LiteNetLib/LiteNetLibClient.cs:99
private void ReceivedNetworkData(NetPeer peer, NetDataReader reader, 
                                  byte channel, DeliveryMethod deliveryMethod)
{
    int packetDataLength = reader.GetInt();
    byte[] packetData = ArrayPool<byte>.Shared.Rent(packetDataLength);
    
    try
    {
        reader.GetBytes(packetData, packetDataLength);
        Packet packet = Packet.Deserialize(packetData);
        packetReceiver.Add(packet);  // Queue for processing
        networkDebugger?.PacketReceived(packet, packetDataLength);
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(packetData, true);
    }
}
Packets are queued in PacketReceiver and processed on the Unity main thread to safely interact with game objects.

Server-Side Networking

Server Initialization

// Nitrox.Server.Subnautica/Models/Communication/LiteNetLibServer.cs
public class LiteNetLibServer
{
    private readonly NetManager server;
    private readonly EventBasedNetListener listener;

    public LiteNetLibServer(int port)
    {
        listener = new EventBasedNetListener();
        listener.ConnectionRequestEvent += OnConnectionRequest;
        listener.PeerConnectedEvent += OnPeerConnected;
        listener.PeerDisconnectedEvent += OnPeerDisconnected;
        listener.NetworkReceiveEvent += OnNetworkReceive;

        server = new NetManager(listener)
        {
            ChannelsCount = (byte)typeof(Packet.UdpChannelId).GetEnumValues().Length,
            IPv6Enabled = true
        };

        server.Start(port);
    }
}

Broadcasting to Clients

The server can broadcast packets to all clients or specific targets:
// Broadcast to all
public void BroadcastToAll(Packet packet)
{
    byte[] data = packet.Serialize();
    foreach (var peer in server.ConnectedPeerList)
    {
        peer.Send(data, packet.UdpChannel, packet.DeliveryMethod);
    }
}

// Send to specific player
public void SendToPlayer(Packet packet, ushort playerId)
{
    NitroxConnection connection = GetConnection(playerId);
    connection.Send(packet);
}

Packet Processing

Packet Processors

Each packet type has a corresponding processor on the client and server:
// Client-side processor
public class ChatMessageProcessor : ClientPacketProcessor<ChatMessage>
{
    public override void Process(ChatMessage packet)
    {
        // Update UI with chat message
        if (packet.PlayerId == ChatMessage.SERVER_ID)
        {
            AddPlayerChatMessage("SERVER", packet.Text, Color.yellow);
        }
        else
        {
            Player player = playerManager.GetPlayer(packet.PlayerId);
            AddPlayerChatMessage(player.Name, packet.Text, Color.white);
        }
    }
}

Processing Pipeline

Packet Received → Deserialized → Queued → Main Thread → Processor.Process() → Game State Updated
1

Network Thread Receives Data

LiteNetLib listener receives UDP packet data.
2

Packet Deserialization

BinaryPack deserializes bytes to concrete packet type.
3

Queue on Main Thread

Packet is queued in PacketReceiver for processing on Unity’s main thread.
4

Processor Execution

Appropriate PacketProcessor<T> handles the packet and updates game state.

UDP Channels

Nitrox uses separate UDP channels to prevent head-of-line blocking:
public enum UdpChannelId : byte
{
    DEFAULT = 0,    // Most game events
    MOVEMENTS = 1,  // Player position/rotation updates
}
Movement packets on a separate channel ensure they don’t get blocked waiting for building construction packets.

Network Debugging

Nitrox includes built-in network debugging tools:

Packet Debugger Interface

public interface INetworkDebugger
{
    void PacketSent(Packet packet, int length);
    void PacketReceived(Packet packet, int length);
}

Logging Network Traffic

networkDebugger?.PacketSent(packet, dataWriter.Length);
This allows tracking:
  • Packet types and frequency
  • Bandwidth usage per packet type
  • Latency measurements
  • Packet loss detection

Connection State Management

Clients go through several connection states:
public enum ConnectionState
{
    Disconnected,
    Connecting,
    Connected,
    InGame,
}

Connection Flow

1

Disconnected

Initial state. Client is not connected to any server.
2

Connecting

Client has initiated connection but not yet received server acknowledgment.
3

Connected

UDP connection established, negotiating session parameters.
4

InGame

Fully synchronized and actively playing in the multiplayer session.

Clock Synchronization

Multiplayer games require synchronized time across clients:
// NitroxClient/Communication/NetworkingLayer/LiteNetLib/ClockSyncProcedure.cs
public class ClockSyncProcedure
{
    public async Task<long> GetServerTimeDelta()
    {
        // Send multiple ping requests
        // Calculate round-trip time
        // Estimate server clock offset
        // Return delta for local clock correction
    }
}
Usage:
  • Synchronize timed events (Aurora explosion)
  • Coordinate player actions
  • Prevent time-based desync
Nitrox uses Network Time Protocol (NTP) concepts to maintain sub-second time synchronization between server and clients.

Performance Considerations

Bandwidth Optimization

// Good: Unreliable for frequent, non-critical updates
public class PlayerMovement : Packet
{
    public PlayerMovement()
    {
        DeliveryMethod = DeliveryMethod.UNRELIABLE_SEQUENCED;
        UdpChannel = UdpChannelId.MOVEMENTS;
    }
}

// Bad: Reliable ordered for movement
DeliveryMethod = DeliveryMethod.RELIABLE_ORDERED; // Causes congestion!
Only send changed values, not entire state:
public class PlayerStateUpdate : Packet
{
    public Optional<Vector3> Position { get; }  // Only if changed
    public Optional<float> Health { get; }      // Only if changed
}

Latency Handling

The client tracks latency for UI display and prediction:
listener.NetworkLatencyUpdateEvent += (peer, _) =>
{
    LatencyUpdateCallback?.Invoke(peer.RemoteTimeDelta);
};

Common Networking Patterns

Request-Response Pattern

// Client sends request
Resolve<IPacketSender>().Send(new BuildingResyncRequest(baseId));

// Server responds with data
public class BuildingResyncRequestProcessor : ServerPacketProcessor<BuildingResyncRequest>
{
    public override void Process(BuildingResyncRequest packet, Player player)
    {
        BaseData baseData = GetBaseData(packet.BaseId);
        player.SendPacket(new BuildingResync(baseData));
    }
}

Broadcast Pattern

// Server receives event from one client
public override void Process(ChatMessage packet, Player sender)
{
    // Broadcast to all players including sender
    server.BroadcastToAll(packet);
}

Authority Pattern

// Client requests action
Resolve<IPacketSender>().Send(new ConstructPiece(baseId, pieceData));

// Server validates and authorizes
public override void Process(ConstructPiece packet, Player player)
{
    if (!ValidateConstruction(packet))
    {
        player.SendPacket(new ConstructionDenied(packet.PieceId));
        return;
    }
    
    ApplyConstruction(packet);
    server.BroadcastToAll(new PieceConstructed(packet));
}

Next Steps

Patching System

Learn how to send packets from Harmony patches

Contributing

Ready to contribute? Read our contribution guidelines

Build docs developers (and LLMs) love