NetMessaging — Reliable and Unreliable Channels
NetMessages is the system-level message dispatch layer sitting between the transport implementation and the application-level handlers. It defines two sets of message IDs — EClientMessage (server-to-client) and EServerMessage (client-to-server) — and provides static methods for sending and receiving them. Each message ID maps to a typed handler that processes a specific system-level operation: connection handshake, ping, workshop sync, RPC invocation, player events, and authentication.
This article covers the message channel types, the send/receive dispatch pipeline, ordering and reliability semantics, fragmentation of workshop downloads, channel bandwidth allocation, and how the system-level messages integrate with the higher-level RPC system.
Source code location: NetMessaging/*.cs
Message IDs
EClientMessage (Server → Client)
csharp
public enum EClientMessage : byte
{
UPDATE_RELIABLE_BUFFER, // 0 — Legacy reliable data
UPDATE_UNRELIABLE_BUFFER, // 1 — Legacy unreliable data
PingRequest, // 2
PingResponse, // 3
Shutdown, // 4
PlayerConnected, // 5
PlayerDisconnected, // 6
DownloadWorkshopFiles, // 7
Verify, // 8
Accepted, // 9
Rejected, // 10
Banned, // 11
Kicked, // 12
Admined, // 13
Unadmined, // 14
ThirdpartyAntiCheat, // 15 — WITH_THIRDPARTYAC only
QueuePositionChanged, // 16
InvokeMethod, // 17 — NetInvokable RPC delivery
ReplicateConfig // 18 — Config sync from server
}EServerMessage (Client → Server)
csharp
public enum EServerMessage : byte
{
GetWorkshopFiles, // 0
ReadyToConnect, // 1
Authenticate, // 2
ThirdPartyAntiCheat, // 3 — WITH_THIRDPARTYAC only
PingRequest, // 4
PingResponse, // 5
InvokeMethod, // 6 — NetInvokable RPC delivery
ValidateAssets, // 7
GracefullyDisconnect // 8
}The enum values are written as the first field in every message using writer.WriteEnum(index) / reader.ReadEnum(out index). Enum bit-count is optimized by the NetPak system (only as many bits as needed to cover the enum range).
Send API
Server-to-Client (Single)
csharp
public static void SendMessageToClient(EClientMessage index, ENetReliability reliability,
ITransportConnection transportConnection, ClientWriteHandler callback)
{
writer.Reset();
writer.WriteEnum(index);
callback(writer);
writer.Flush();
#if LOG_SEND_TO_CLIENT_ERRORS
if (writer.errors != NetPakWriter.EErrorFlags.None)
UnturnedLog.error("Error {0} writing message {1} to client {2}", writer.errors, index, transportConnection);
#endif
transportConnection.Send(writer.buffer, writer.writeByteIndex, reliability);
}The method:
- Obtains a shared
NetPakWriterfrom the static pool. - Resets the writer to clear any previous data.
- Writes the message enum as the first field.
- Calls the user-provided
callback(writer)to write the message payload. - Flushes the writer to ensure all buffered bits are written to the byte buffer.
- Sends the raw buffer via
ITransportConnection.Send().
Server-to-Client (Broadcast)
csharp
public static void SendMessageToClients(EClientMessage index, ENetReliability reliability,
List<ITransportConnection> transportConnections, ClientWriteHandler callback)
{
writer.Reset();
writer.WriteEnum(index);
callback(writer);
writer.Flush();
foreach (ITransportConnection transportConnection in transportConnections)
{
transportConnection.Send(writer.buffer, writer.writeByteIndex, reliability);
}
}Writes the message once and sends the same buffer to every connection in the list. This amortizes serialization cost across all recipients. The shared writer and reader are static fields — the methods are not re-entrant but are called exclusively from the game thread.
Client-to-Server
csharp
public static void SendMessageToServer(EServerMessage index, ENetReliability reliability,
ClientWriteHandler callback)
{
if (!Provider.isConnected)
{
UnturnedLog.warn($"Ignoring request to send message {index} to server because we are not connected");
return;
}
writer.Reset();
writer.WriteEnum(index);
callback(writer);
writer.Flush();
Provider.clientTransport.Send(writer.buffer, writer.writeByteIndex, reliability);
}Same pattern but sends via Provider.clientTransport.Send(). Guarded by Provider.isConnected — if not connected, the send is silently dropped with a warning log.
Receive API
Server Receiving from Client
csharp
public static void ReceiveMessageFromClient(ITransportConnection transportConnection,
byte[] packet, int offset, int size)
{
reader.SetBufferSegment(packet, size);
reader.Reset();
EServerMessage index;
if (!reader.ReadEnum(out index))
{
UnturnedLog.warn("Received invalid packet index from {0}", transportConnection);
Provider.refuseGarbageConnection(transportConnection, "sv invalid packet index");
return;
}
try
{
serverReadCallbacks[(int)index]?.Invoke(transportConnection, reader);
#if LOG_RECEIVE_FROM_CLIENT_ERRORS
if (reader.errors != NetPakReader.EErrorFlags.None)
UnturnedLog.error("Error {0} reading message {1} from client {2}", reader.errors, index, transportConnection);
else if (!reader.ReachedEndOfSegment)
UnturnedLog.warn("Did not read to end of message {0} from client {1}", index, transportConnection);
#endif
}
catch (System.Exception e)
{
UnturnedLog.exception(e, "Exception reading message {0} from client {1}:", index, transportConnection);
}
}- Sets the reader buffer segment to the received data.
- Reads the
EServerMessageenum. - If the enum read fails (invalid/unrecognized), calls
Provider.refuseGarbageConnection()to disconnect the misbehaving client. - Dispatches to
serverReadCallbacks[(int)index]. If the handler is null, the packet is silently dropped. - After handler execution, checks for read errors and unreached end-of-segment (development builds only).
- All exceptions from handlers are caught and logged to prevent the message loop from falling behind.
Client Receiving from Server
csharp
public static void ReceiveMessageFromServer(byte[] packet, int offset, int size)
{
reader.SetBufferSegment(packet, size);
reader.Reset();
EClientMessage index;
if (!reader.ReadEnum(out index))
{
UnturnedLog.error("Client received invalid message index from server");
return;
}
try
{
switch (index)
{
case EClientMessage.UPDATE_RELIABLE_BUFFER:
case EClientMessage.UPDATE_UNRELIABLE_BUFFER:
reader.AlignToByte();
Provider.legacyReceiveClient(packet, offset, size);
break;
default:
Provider.timeLastPacketWasReceivedFromServer = Time.realtimeSinceStartup;
clientReadCallbacks[(int)index]?.Invoke(reader);
break;
}
}
catch (System.Exception e)
{
UnturnedLog.exception(e, "Exception reading message {0} from server:", index);
}
}- Reads the
EClientMessageenum. - Legacy handling:
UPDATE_RELIABLE_BUFFERandUPDATE_UNRELIABLE_BUFFERare routed toProvider.legacyReceiveClient()after byte alignment. These use the oldSteamPackerreader and are bridged viaAlignToByte(). - All other messages dispatch to
clientReadCallbacks[(int)index]. - Updates
Provider.timeLastPacketWasReceivedFromServerfor timeout detection.
Handler Registration
The handler dispatch arrays are populated in the static constructor:
csharp
static NetMessages()
{
reader = new NetPakReader();
writer = new NetPakWriter();
writer.buffer = Block.buffer;
clientReadCallbacks = new ClientReadHandler[System.Enum.GetNames(typeof(EClientMessage)).Length];
clientReadCallbacks[(int)EClientMessage.PingRequest] = ClientMessageHandler_PingRequest.ReadMessage;
clientReadCallbacks[(int)EClientMessage.PingResponse] = ClientMessageHandler_PingResponse.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Shutdown] = ClientMessageHandler_Shutdown.ReadMessage;
clientReadCallbacks[(int)EClientMessage.PlayerConnected] = ClientMessageHandler_PlayerConnected.ReadMessage;
clientReadCallbacks[(int)EClientMessage.PlayerDisconnected] = ClientMessageHandler_PlayerDisconnected.ReadMessage;
clientReadCallbacks[(int)EClientMessage.DownloadWorkshopFiles] = ClientMessageHandler_DownloadWorkshopFiles.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Verify] = ClientMessageHandler_Verify.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Accepted] = ClientMessageHandler_Accepted.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Rejected] = ClientMessageHandler_Rejected.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Banned] = ClientMessageHandler_Banned.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Kicked] = ClientMessageHandler_Kicked.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Admined] = ClientMessageHandler_Admined.ReadMessage;
clientReadCallbacks[(int)EClientMessage.Unadmined] = ClientMessageHandler_Unadmined.ReadMessage;
clientReadCallbacks[(int)EClientMessage.QueuePositionChanged] = ClientMessageHandler_QueuePositionChanged.ReadMessage;
clientReadCallbacks[(int)EClientMessage.InvokeMethod] = ClientMessageHandler_InvokeMethod.ReadMessage;
clientReadCallbacks[(int)EClientMessage.ReplicateConfig] = ClientMessageHandler_ReplicateConfig.ReadMessage;
serverReadCallbacks = new ServerReadHandler[System.Enum.GetNames(typeof(EServerMessage)).Length];
serverReadCallbacks[(int)EServerMessage.GetWorkshopFiles] = ServerMessageHandler_GetWorkshopFiles.ReadMessage;
serverReadCallbacks[(int)EServerMessage.ReadyToConnect] = ServerMessageHandler_ReadyToConnect.ReadMessage;
serverReadCallbacks[(int)EServerMessage.Authenticate] = ServerMessageHandler_Authenticate.ReadMessage;
serverReadCallbacks[(int)EServerMessage.PingRequest] = ServerMessageHandler_PingRequest.ReadMessage;
serverReadCallbacks[(int)EServerMessage.PingResponse] = ServerMessageHandler_PingResponse.ReadMessage;
serverReadCallbacks[(int)EServerMessage.InvokeMethod] = ServerMessageHandler_InvokeMethod.ReadMessage;
serverReadCallbacks[(int)EServerMessage.ValidateAssets] = ServerMessageHandler_ValidateAssets.ReadMessage;
serverReadCallbacks[(int)EServerMessage.GracefullyDisconnect] = ServerMessageHandler_GracefullyDisconnect.ReadMessage;
}In editor/development builds, null entries in the handler arrays are logged as warnings to ensure no gaps.
Handler Details
Ping System
PingRequest / PingResponse — bidirectional ping measurement. The server periodically sends PingRequest to each client at PING_REQUEST_INTERVAL (1 second). The client responds with PingResponse. This measures round-trip time and detects dead connections. Connection timeout is CLIENT_TIMEOUT (30 seconds). The client updates Provider.timeLastPacketWasReceivedFromServer on every received message.
Connection Handshake
ReplicateConfig— server sends mode config data to newly connected clients so they mirror server settings for gameplay calculations (damage multipliers, event timings, etc.).ValidateAssets— client reports asset hashes to the server after loading to ensure no asset mismatch (prevents clients with modified assets from connecting).Accepted— server accepts the client with spawn position, NetId block, equipment, skills, and initial player state.Rejected/Banned/Kicked— server rejects the client with a reason (server full, banned, invalid version, etc.).QueuePositionChanged— notifies a queued player of their current position in the connection queue.Admined/Unadmined— server toggles admin status for a client, affecting command permissions and UI indicators.GracefullyDisconnect— client tells the server it's disconnecting intentionally (vs. timeout or crash), so the server can immediately free the slot.Shutdown— server announces imminent shutdown to all connected clients.DownloadWorkshopFiles— server sends workshop file IDs to the client for download.
RPC Invocation
InvokeMessage is the most heavily used message channel — it carries all NetInvokable RPC calls. The handlers for this message read the method index and dispatch to the generated read method:
csharp
// Client receives server RPC
public static void ReadMessage(NetPakReader reader)
{
uint methodIndex;
reader.ReadUIntBits(out methodIndex, NetReflection.clientMethodsBitCount);
ClientMethodInfo clientMethod = NetReflection.clientMethods[(int)methodIndex];
clientMethod.readMethod(new ClientInvocationContext(reader));
}
// Server receives client RPC
public static void ReadMessage(ITransportConnection transportConnection, NetPakReader reader)
{
uint methodIndex;
reader.ReadUIntBits(out methodIndex, NetReflection.serverMethodsBitCount);
ServerMethodInfo serverMethod = NetReflection.serverMethods[(int)methodIndex];
// Rate limit check
if (serverMethod.rateLimitIndex >= 0)
{
// Check per-connection rate limit
}
serverMethod.readMethod(new ServerInvocationContext(transportConnection, reader));
}Reliability Semantics
Messages can be sent with ENetReliability.Reliable or ENetReliability.Unreliable. The choice depends on the message type:
Reliable Messages
Delivered with guarantee. The transport layer handles retransmission on loss. Used for:
- Connection handshake (
Accepted,Rejected,Banned,Kicked). - Player events (
PlayerConnected,PlayerDisconnected). - RPC invocations for critical state changes (spawn, damage, inventory updates).
- Config replication (
ReplicateConfig). - Admin commands (
Admined,Unadmined). - Ban/kick commands.
- Ping requests — reliable delivery is important for accurate timeout detection.
- Workshop file download commands.
Unreliable Messages
Fire-and-forget with no retransmission. Used for:
- Legacy position updates (
UPDATE_UNRELIABLE_BUFFER). - Voice data — late packets are discarded in favor of newer audio.
- High-frequency state syncs where occasional loss is acceptable (character pose updates, physics interpolation state).
- Third-party anti-cheat heartbeats (can be high-frequency, loss-tolerant).
Fragmentation
Workshop file downloads use DownloadWorkshopFiles which handles fragmentation at the application level. The client handler receives chunks of file data and reassembles them into the complete workshop file.
The legacy system used UPDATE_RELIABLE_CHUNK_BUFFER / UPDATE_UNRELIABLE_CHUNK_BUFFER with a chunk-based fragmentation scheme: large messages were split into 1024-byte chunks with sequence numbers for reassembly. This legacy path uses Provider.legacyReceiveClient() which reads chunks from the SteamPacker buffer.
The current system relies on the transport layer for fragmentation — SteamNetworkingSockets handles MTU and fragmentation transparently. SystemSockets uses SocketMessageLayer for length-prefixed framing with application-level reassembly.
Bandwidth and Rate Control
There is no explicit bandwidth allocation per message type in the current system. However:
- The rate limiting system for NetInvokable methods provides implicit bandwidth control for RPC calls. Methods marked with
[SteamCall(ratelimitHz = N)]are dropped if a client exceeds N invocations/second. - Legacy UPDATE_BUFFER messages had priority categories — player movement updates could be dropped under load while reliable state syncs were always delivered (handled by the transport layer's priority system).
- The transport-level bandwidth management is configured per-transport:
- SNS:
k_ESteamNetworkingConfig_SendBufferSizecontrols the per-connection send buffer (default 0 = auto).k_ESteamNetworkingConfig_TimeoutConnectedcontrols the receive timeout. - SystemSockets: relies on TCP's built-in flow control.
- Legacy SteamNetworking: no congestion control (one reason for its deprecation).
- SNS:
The shared writer and reader pattern means serialization is single-threaded — all message writing and reading happens on the Unity game thread. This eliminates thread-safety concerns but means serialization time is part of the frame budget.
InvokeMethod Handler Detail
The InvokeMethod handler is the most performance-critical path in the messaging system:
csharp
// Client-side: receiving an RPC from server
public static void ReadMessage(NetPakReader reader)
{
uint methodIndex;
reader.ReadUIntBits(out methodIndex, NetReflection.clientMethodsBitCount);
ClientMethodInfo clientMethod = NetReflection.clientMethods[(int)methodIndex];
#if PROFILE_NET_MESSAGE_READ_HANDLERS
clientMethod.readSampler.Begin();
#endif
clientMethod.readMethod(new ClientInvocationContext(reader));
#if PROFILE_NET_MESSAGE_READ_HANDLERS
clientMethod.readSampler.End();
#endif
}
// Server-side: receiving an RPC from client
public static void ReadMessage(ITransportConnection transportConnection, NetPakReader reader)
{
uint methodIndex;
reader.ReadUIntBits(out methodIndex, NetReflection.serverMethodsBitCount);
ServerMethodInfo serverMethod = NetReflection.serverMethods[(int)methodIndex];
// Rate limiting check
SteamPlayer player = Provider.findPlayer(transportConnection);
if (player != null && serverMethod.rateLimitIndex >= 0)
{
float currentTime = Time.realtimeSinceStartup;
float lastInvoke = player.GetRateLimitTimestamp(serverMethod.rateLimitIndex);
if (currentTime - lastInvoke < serverMethod.ratelimitSeconds)
{
// Exceeded rate limit — silently drop
return;
}
player.SetRateLimitTimestamp(serverMethod.rateLimitIndex, currentTime);
}
serverMethod.readMethod(new ServerInvocationContext(transportConnection, reader));
}The server-side handler includes rate limit enforcement before calling the method. This prevents clients from spamming RPC methods like movement updates or damage requests.
ReplicateConfig Handler
ClientMessageHandler_ReplicateConfig handles server-to-client config replication:
csharp
public static void ReadMessage(NetPakReader reader)
{
// Read the entire mode config data from server
// This includes: damage multipliers, event timings, spawn settings,
// inventory settings, barricade/structure limits, loot tables
Provider.modeConfigData = new ModeConfigData();
Provider.modeConfigData.read(reader);
// Apply server overrides
CommandWindow.Log("Received server config");
}This ensures clients mirror the server's configuration for gameplay calculations. The config data includes arena timers, compactor speeds, airdrop frequencies, damage multipliers, and event settings.
Accepted Handler
ClientMessageHandler_Accepted.ReadMessage() handles the server's acceptance of a client join:
csharp
public static void ReadMessage(NetPakReader reader)
{
// Read player ID, spawn position, initial stance
NetId netId;
reader.ReadNetId(out netId);
Vector3 position;
reader.ReadClampedVector3(out position);
byte angle;
reader.ReadUInt8(out angle);
// Read character customization
byte face, hair, beard;
reader.ReadUInt8(out face);
// ... read all 30+ character parameters
// Initialize the player in the game world
Provider.AddLocalPlayer(netId, position, angle, ...);
}Rate Limiting
Beyond per-method RPC rate limiting (in NetInvokable), the messaging system has two additional rate control mechanisms:
Connection-level bad message limiting:
TransportConnectionRateLimitertracks per-connection counts of invalid or malformed packets. If a connection exceeds the threshold, it's terminated.Ping timeout:
Provider.timeLastPacketWasReceivedFromServeris updated on every received message. If no message is received withinCLIENT_TIMEOUT(30 seconds), the client considers the server dead and disconnects. On the server side, missing ping responses from a client for 30 seconds triggers a timeout disconnect.
Thread Safety
The NetMessages reader and writer are not thread-safe — they are static fields shared across all message operations. This is acceptable because:
- All networking code runs on the Unity game thread.
- The transport layer's receive callbacks are called from the game thread (the transport polls Steam's message queue on the game thread).
- No background threads produce or consume network messages.
This design simplifies the code but means any future multi-threading (e.g., dedicated send/receive threads) would require per-thread reader/writer instances.
Message Ordering Guarantees
The messaging system provides ordering guarantees through the transport layer:
Reliable messages: Ordered delivery within a single connection. If message A is sent before message B, A is delivered before B. This holds across message types — an
InvokeMethodsent afterReplicateConfigwill arrive after it.Unreliable messages: No ordering guarantee. Late-arriving unreliable messages are delivered (not reordered or dropped). Applications must handle out-of-order unreliable data (e.g., position updates should be idempotent).
Mixed reliable/unreliable: No ordering guarantee between reliable and unreliable channels. An unreliable message may arrive before or after a reliable message sent earlier.
Message Statistics
Provider tracks global messaging statistics:
csharp
private static uint _bytesSent;
private static uint _bytesReceived;
private static uint _packetsSent;
private static uint _packetsReceived;These counters are exposed as public static properties (Provider.bytesSent, Provider.packetsReceived, etc.) and can be read by monitoring tools or admin commands. They reset on level load or server restart. The counters reflect the transport layer's byte/packet counts, not individual messages (a single packet may contain multiple messages in the legacy UPDATE_BUFFER path).
Connection Quality Detection
The ping message pair (PingRequest / PingResponse) enables connection quality monitoring:
- Server sends
PingRequestto each client everyPING_REQUEST_INTERVAL(1 second). - Client responds with
PingResponsecontaining the original timestamp. - Server calculates round-trip time = current time - timestamp.
- If no response within
CLIENT_TIMEOUT(30 seconds), the connection is considered dead. - Clients also track
timeLastPacketWasReceivedFromServer— if this exceedsCLIENT_TIMEOUT, the client disconnects.
The ping interval and timeout are intentionally asymmetric: the 1-second ping provides timely failure detection, while the 30-second timeout prevents brief network hiccups from causing disconnections.
Legacy Message Compatibility
UPDATE_RELIABLE_BUFFER and UPDATE_UNRELIABLE_BUFFER are legacy messages using the old Block-based serialization (the SteamPacker system). When received:
reader.AlignToByte()pads the bit reader to the next byte boundary.- The raw byte data (from the message offset) is passed to
Provider.legacyReceiveClient(). - The legacy system reads using its own
SteamPackerreader from the byte buffer.
This bridge supports old code that hasn't been migrated to the NetPak-based system. The legacy messages are still used by some manager classes that have not been updated to use NetInvokable handles. The AlignToByte() step is critical because the NetPak reader may have consumed a partial byte for the message enum, and the legacy reader expects to start on a clean byte boundary.
Message Flow Example: Player Joining
The following messages are exchanged when a client joins a server:
- Client → Server:
EServerMessage.Authenticate— sends Steam auth ticket, character data, equipped item IDs, language preference. - Server → Client:
EClientMessage.DownloadWorkshopFiles— sends list of required Workshop file IDs if the server uses mods. - Client → Server:
EServerMessage.GetWorkshopFiles— confirms Workshop files are downloaded and valid. - Server → Client:
EClientMessage.Accepted— sends NetId block, spawn position, initial equipment, skills, stats, permissions. - Server → Client:
EClientMessage.ReplicateConfig— sends mode config data. - Server → Client:
EClientMessage.PlayerConnected— notifies other clients of the new player. - Client → Server:
EServerMessage.InvokeMethod— player input RPCs begin flowing. - Server → Client:
EClientMessage.InvokeMethod— server state replication RPCs begin flowing. - Bidirectional:
PingRequest/PingResponse— periodic ping exchange for timeout detection.
Message Flow Example: Server Shutdown
- Server → All Clients:
EClientMessage.Shutdown— announces shutdown to all connected clients. - Clients display "Server shutting down" message.
- Server waits briefly, then closes all transport connections.
Legacy Message Flow Example: Position Sync
Pre-NetInvokable position synchronization used the legacy UPDATE_BUFFER messages:
- Server → Client:
EClientMessage.UPDATE_UNRELIABLE_BUFFER— contains packed position data for all entities within the client's visibility range. - The client reads the byte-aligned buffer via
Provider.legacyReceiveClient(), which uses the oldSteamPackerreader. - Each entity update includes: NetId, position (Vector3), rotation (angle), velocity (Vector3), and animation state.
- Updates are sent at a fixed rate (typically 10-20 Hz).
- Reliable variant (
UPDATE_RELIABLE_BUFFER) is used for critical entity state changes where drops cannot be tolerated.
Network Profiling
In development builds, the messaging system enables profiler samplers for each message handler:
csharp
#if PROFILE_NET_MESSAGE_READ_HANDLERS
clientSamplers[(int)index].Begin();
clientReadCallbacks[(int)index]?.Invoke(reader);
clientSamplers[(int)index].End();
#endifEach handler gets a CustomSampler named {DeclaringType}.{MethodName}. The Unity Profiler can be used to analyze per-message CPU costs. The most expensive handlers are typically InvokeMethod (RPC dispatch) and ReplicateConfig (config data parsing).
Implementation-Specific Registration
Some message handlers use conditional compilation:
csharp
#if WITH_THIRDPARTYAC
clientReadCallbacks[(int)EClientMessage.ThirdpartyAntiCheat] = ClientMessageHandler_ThirdpartyAntiCheat.ReadMessage;
serverReadCallbacks[(int)EServerMessage.ThirdPartyAntiCheat] = ServerMessageHandler_ThirdpartyAntiCheat.ReadMessage;
#endifThe WITH_THIRDPARTYAC define gates handlers that are not compiled into standard builds. Without the define, these message IDs still exist in the enum but have null handlers — received messages are silently dropped.
Handler Validation
Development builds verify the handler table is complete:
csharp
#if UNITY_EDITOR || DEVELOPMENT_BUILD
for (int index = 2; index < clientReadCallbacks.Length; ++index)
if (clientReadCallbacks[index] == null)
UnturnedLog.info("Missing client message handler {0}", index);
for (int index = 0; index < serverReadCallbacks.Length; ++index)
if (serverReadCallbacks[index] == null)
UnturnedLog.info("Missing server message handler {0}", index);
#endifIndices 0 and 1 (UPDATE_RELIABLE/UNRELIABLE_BUFFER) are handled in the ReceiveMessageFromServer switch statement, not through the callback array.
Bandwidth Monitoring
The -LogBadMessages command-line flag enables verbose logging of malformed or unexpected message indices. Development builds also log errors during message writing (LOG_SEND_TO_CLIENT_ERRORS, LOG_SEND_TO_SERVER_ERRORS) and reading (LOG_RECEIVE_FROM_CLIENT_ERRORS, LOG_RECEIVE_FROM_SERVER_ERRORS). Messages where the reader didn't reach the end of the segment indicate a mismatch between writer and reader — these are logged as warnings.
Provider tracks global statistics: bytesSent, bytesReceived, packetsSent, packetsReceived. These counters are exposed as public static properties and can be read by monitoring tools. They reset on level load or server restart and reflect the transport layer's totals, not individual message counts.
Compression
The NetMessaging layer does not apply compression to message payloads. Compression is deferred to:
- The transport layer: SNS uses DTLS (no compression), SystemSockets uses raw TCP (no compression).
- The serialization layer: NetPak bit-packing reduces size at the field level.
- Application code: Individual message handlers can compress their payloads before writing.
For typical gameplay messages, bit-packing provides sufficient size reduction without the CPU overhead of general-purpose compression algorithms like zlib or LZ4.
