Skip to content

NetPak Binary Format

NetPak is Unturned's bit-level binary serialization system, replacing the older byte-oriented Block class. It provides NetPakReader and NetPakWriter that operate on individual bits rather than bytes, enabling compact encoding of integers, floats, vectors, quaternions, guids, steam IDs, NetIds, and enums. The NetPakBlockImplementation class bridges the old Block API to the new bit-level reader/writer, allowing incremental migration of existing netcode.

This article covers the bit-packing primitives, the type-specific encoding strategies, vector quantization, quaternion smallest-three compression, enum bit-packing, and the bridge implementation details.

Source code location: NetPak/NetPakBlockImplementation.cs, NetPak/ (reader/writer classes in the SDG.NetPak namespace)

NetPakReader and NetPakWriter

The reader and writer operate on a shared byte buffer with a bit-level cursor. The writer accumulates bits into the buffer; the reader extracts bits from it. Core operations:

csharp
// Writer
void WriteBit(bool value);
void WriteUInt8(byte value);       // 8 bits
void WriteInt16(short value);      // 16 bits
void WriteUInt16(ushort value);    // 16 bits
void WriteInt32(int value);        // 32 bits
void WriteUInt32(uint value);      // 32 bits
void WriteInt64(long value);       // 64 bits
void WriteUInt64(ulong value);     // 64 bits
void WriteFloat(float value);      // 32 bits IEEE 754
void WriteString(string value);    // 16-bit length prefix + UTF-8 bytes
void WriteBytes(byte[] data, int length);
void WriteUIntBits(uint value, int bitCount);  // Arbitrary bit-width unsigned
void WriteIntBits(int value, int bitCount);    // Arbitrary bit-width signed

// Reader (mirror methods)
void ReadBit(out bool value);
void ReadUInt8(out byte value);
// ... etc

All integer types are written with their natural bit width. There is no variable-length integer encoding (VarInt) — the system uses fixed-width encodings because the maximum ranges are known at design time and can be tightly packed. The WriteUIntBits/ReadUIntBits methods are used for method indices, enum values, and small-range integers.

Bit-Packed Integers

csharp
public void WriteUIntBits(uint value, int bitCount)
{
    // Writes 'value' using exactly 'bitCount' bits, least significant bits first
    for (int i = 0; i < bitCount; i++)
    {
        WriteBit((value & (1u << i)) != 0);
    }
}

public void ReadUIntBits(out uint value, int bitCount)
{
    value = 0;
    for (int i = 0; i < bitCount; i++)
    {
        bool bit;
        ReadBit(out bit);
        if (bit) value |= (1u << i);
    }
}

This is used for:

  • Method indices: clientMethodsBitCount bits for client methods in ClientMessageHandler_InvokeMethod. If there are 150 client methods, only 8 bits are used.
  • Enum values: CountBits(enumRange) bits instead of a full 32-bit int. For example, EArenaMessage (8 values) uses 3 bits.
  • Small-range integers: player counts (usually 2-8 bits), region coordinates (4-6 bits), item amounts.

The bit count is determined at registration time:

csharp
clientMethodsBitCount = NetPakConst.CountBits(clientMethodsLength);
serverMethodsBitCount = NetPakConst.CountBits(serverMethodsLength);

NetPakConst.CountBits() returns the minimum number of bits needed to represent the value: CountBits(0) = 0, CountBits(1) = 1, CountBits(150) = 8, CountBits(255) = 8, CountBits(256) = 9.

Packed Vector Types

Clamped Vector3

csharp
public void WriteClampedVector3(Vector3 value, int fracBitCount, int intBitCount = 13)
public void ReadClampedVector3(out Vector3 value, int fracBitCount, int intBitCount = 13)

The vector is quantized to a fixed-point representation:

  1. Each component is clamped to [-4096, 4096) by default (13-bit integer range: [-2^12, 2^12)).
  2. Each component is multiplied by 2^fracBitCount and rounded to an integer.
  3. The integer is written as a signed integer using intBitCount bits.

Default fracBitCount = 9 gives precision of 1/512 ≈ 0.002 units. For barricade/structure editor positions in the NetPakBlockImplementation, fracBitCount: 9 is explicitly specified to maintain precision for fine positioning (mods often modify barricade positions).

For airdrop positions on insane-size maps, intBitCount = 14 extends the range to [-8192, 8192):

csharp
[SteamCall(ESteamCallValidation.ONLY_FROM_SERVER)]
public static void ReceiveAirdropState(
    [NetPakVector3(intBitCount: 14)] Vector3 position,
    [NetPakVectorAsYaw(yawBitCount: 24)] Vector3 velocity)

The [NetPakVector3] attribute allows per-method override of the integer bit count without changing the default encoding.

The total size of a clamped Vector3 is 3 * intBitCount + 3 * fracBitCount bits. With defaults (13 int + 9 frac) that's 66 bits (8.25 bytes), compared to 96 bits (12 bytes) for three raw floats.

Vector as Yaw

[NetPakVectorAsYaw] attribute on a Vector3 parameter causes the vector to be serialized as:

  • Speed (float, 32 bits) — the magnitude.
  • Yaw angle (24 bits) — encoding 0-360 degrees with precision of 360 / 2^24 ≈ 2.1e-5 degrees.
csharp
public void WriteVectorAsYaw(Vector3 value, int yawBitCount)
{
    float speed = value.magnitude;
    float yaw = Mathf.Atan2(value.z, value.x) * Mathf.Rad2Deg;
    WriteFloat(speed);
    WriteUIntBits((uint)(yaw / 360f * (1 << yawBitCount)), yawBitCount);
}

Used by ReceiveAirdropState for dropship velocity, where direction precision matters for trajectory replication. Saves 8 bits per component compared to a full Vector3.

Quaternion Compression

csharp
public void WriteQuaternion(Quaternion value)
public void ReadQuaternion(out Quaternion value)

Uses the "smallest three" compression technique:

  1. Find the index of the largest component (absolute value).
  2. Encode the index as 2 bits ([0, 1, 2, 3] for [x, y, z, w]).
  3. Encode the remaining three components as signed 10-bit integers in range [-1, 1], scaled by √2/2 for maximum precision in the unit quaternion space.
csharp
// Encoding:
float a0 = Mathf.Abs(value.x);
float a1 = Mathf.Abs(value.y);
float a2 = Mathf.Abs(value.z);
float a3 = Mathf.Abs(value.w);

// Find largest component index
int largestIndex = 0;
float largest = a0;
if (a1 > largest) { largest = a1; largestIndex = 1; }
if (a2 > largest) { largest = a2; largestIndex = 2; }
if (a3 > largest) { largestIndex = 3; }

// Encode the three smaller components
float[] components = { value.x, value.y, value.z, value.w };
int writeIndex = 0;
for (int i = 0; i < 4; i++)
{
    if (i == largestIndex) continue;
    // Scale to [-1, 1] range for 10-bit encoding
    long encoded = (long)(components[i] * (1 << 9));
    WriteIntBits((int)encoded, 10);
}

Total: 2 bits (largest index) + 3 × 10 bits = 32 bits, versus 128 bits for 4 raw floats. Reconstruction normalizes the quaternion and ensures the largest component has the correct sign via the identity w² + x² + y² + z² = 1.

Color Encoding

csharp
public void WriteColor32RGB(Color32 value)
public void ReadColor32RGB(out Color32 value)

Writes R, G, B as 8-bit each (24 bits total). Alpha is not written — defaulted to 255 on read. This is used for player customization colors, chat colors, and UI element tints.

NetId Encoding

csharp
public void WriteNetId(NetId value)
public void ReadNetId(out NetId value)

NetId is a 32-bit unsigned integer (uint). It is written as a full UInt32 (32 bits). NetIds are allocated in blocks per-player and are unique per-level-load session. The full 32-bit encoding supports up to ~4 billion unique NetIds across all players and runtime-created objects.

SteamID Encoding

csharp
public void WriteSteamID(CSteamID value)
public void ReadSteamID(out CSteamID value)

Encoded as a 64-bit unsigned integer (UInt64). The SteamID is the full 64-bit CSteamID value. No compression is applied because SteamIDs are opaque 64-bit identifiers with no compressible structure. Used for player identification, friend lists, group membership, and ban lists.

GUID Encoding

csharp
public void WriteGuid(Guid value)
public void ReadGuid(out Guid value)

Encoded as 128 bits (16 bytes) with no compression. Used for asset GUID references where collision-free identification is required across the entire asset registry. GUIDs are stored in the asset system as System.Guid and referenced in configs and savedata.

String Encoding

csharp
public void WriteString(string value)
{
    byte[] utf8Bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
    ushort length = (ushort)Math.Min(utf8Bytes.Length, ushort.MaxValue);
    WriteUInt16(length);
    WriteBytes(utf8Bytes, length);
}

public void ReadString(out string value)
{
    ushort length;
    ReadUInt16(out length);
    byte[] utf8Bytes = new byte[length];
    ReadBytes(utf8Bytes);
    value = Encoding.UTF8.GetString(utf8Bytes);
}

Strings are written as:

  1. A 16-bit unsigned integer length prefix (max 65535 bytes).
  2. The UTF-8 encoded bytes of the string.

Null strings are encoded as length 0. The ReadString method always returns a non-null string (empty string for length 0). UTF-8 encoding ensures international character support while being ascii-efficient for common English text.

Enum Encoding

Enums are written via WriteEnum<T>() / ReadEnum<T>(). The implementation uses the metadata emitted by the NetEnum code generator:

  1. Determines the underlying integer type (byte, ushort, int, etc.).
  2. Determines the bit count needed to represent the full enum range (CountBits(maxValue + 1)).
  3. Writes the enum's integer value as a UIntBits with the computed bit count.

For example:

  • EArenaMessage (8 values: LOBBY, WARMUP, PLAY, DIED, ABANDONED, WIN, LOSE, INTERMISSION) → 3 bits.
  • EGameMode (5 values: EASY, NORMAL, HARD, ANY, TUTORIAL) → 3 bits.
  • EClientMessage (19 values) → 5 bits.
  • EServerMessage (9 values) → 4 bits.

The generated NetEnum code provides extension methods on the reader/writer:

csharp
public static void WriteEnum(this NetPakWriter writer, EArenaMessage value)
{
    writer.WriteUIntBits((uint)value, 3);
}

public static void ReadEnum(this NetPakReader reader, out EArenaMessage value)
{
    uint bits;
    reader.ReadUIntBits(out bits, 3);
    value = (EArenaMessage)bits;
}

Array Encoding

Arrays are length-prefixed with the element type's natural encoding:

  • string[]: byte length prefix + each string via ReadString.
  • bool[]: ushort length prefix + each as ReadBit.
  • byte[]: byte length prefix + ReadBytes.
  • int[]: ushort length prefix + each as ReadInt32.
  • ulong[]: ushort length prefix + each as ReadUInt64.

Length types vary by expected array size:

  • Small arrays (strings, bytes): byte length (max 255).
  • Large arrays (ints, ulongs, bools): ushort length (max 65535).

NetPakBlockImplementation Bridge

NetPakBlockImplementation provides the same API as the old Block class but backed by NetPakReader/NetPakWriter. It was created to allow incremental migration of old tell/ask RPC methods to the new bit-level system without rewriting all call sites at once.

Read Interface

csharp
public object read(Type type)
{
    if (type == Types.STRING_TYPE)
    {
        string value; reader.ReadString(out value); return value;
    }
    else if (type == Types.BOOLEAN_TYPE)
    {
        bool value; reader.ReadBit(out value); return value;
    }
    else if (type == Types.BYTE_TYPE)
    {
        byte value; reader.ReadUInt8(out value); return value;
    }
    else if (type == Types.INT16_TYPE)
    {
        short value; reader.ReadInt16(out value); return value;
    }
    else if (type == Types.INT32_TYPE)
    {
        int value; reader.ReadInt32(out value); return value;
    }
    else if (type == Types.UINT32_TYPE)
    {
        uint value; reader.ReadUInt32(out value); return value;
    }
    else if (type == Types.SINGLE_TYPE)
    {
        float value; reader.ReadFloat(out value); return value;
    }
    else if (type == Types.VECTOR3_TYPE)
    {
        Vector3 value; reader.ReadClampedVector3(out value, fracBitCount: 9); return value;
    }
    else if (type == Types.QUATERNION_TYPE)
    {
        Quaternion value; reader.ReadQuaternion(out value); return value;
    }
    else if (type == typeof(NetId))
    {
        NetId value; reader.ReadNetId(out value); return value;
    }
    // ... etc
}

The dispatcher handles all standard types. Array types have special handling for reading lengths then looping.

Write Interface

csharp
public void write(object objects)
{
    Type type = objects.GetType();
    if (type == Types.STRING_TYPE)
        writer.WriteString((string)objects);
    else if (type == Types.BOOLEAN_TYPE)
        writer.WriteBit((bool)objects);
    else if (type == Types.BYTE_TYPE)
        writer.WriteUInt8((byte)objects);
    else if (type == Types.VECTOR3_TYPE)
        writer.WriteClampedVector3((Vector3)objects, fracBitCount: 9);
    else if (type == Types.QUATERNION_TYPE)
        writer.WriteQuaternion((Quaternion)objects);
    else if (type == typeof(NetId))
        writer.WriteNetId((NetId)objects);
    // ... etc
}

Buffer Reset

csharp
public void resetForRead(int prefix, byte[] buffer, int size)
{
#if WITH_NETPAK_EXCEPTIONS || UNITY_EDITOR || DEVELOPMENT_BUILD
    reader.SetBufferSegment(buffer, size);  // Restrict to actual data
#else
    reader.SetBuffer(buffer);  // Allow reading full buffer (legacy compat)
#endif
    reader.Reset();
    reader.readByteIndex = prefix;
}

public void resetForWrite(int prefix)
{
    writer.Reset();
    writer.writeByteIndex = prefix;
}

The debug/release distinction for SetBufferSegment vs SetBuffer exists because legacy RPCs may send less data than the handler expects. In release builds, reading beyond the sent data is tolerated to avoid breaking old mods.

getBytes

csharp
public byte[] getBytes(out int size)
{
    writer.Flush();
    size = writer.writeByteIndex;
    return writer.buffer;
}

Wire Format Examples

Boolean + Byte (1 + 8 bits)

[bool: 1 bit][uint8: 8 bits]

String "hello" (16 + 40 bits)

[length: 16 bits = 5][UTF-8 bytes: 40 bits = h e l l o]

Clamped Vector3 (-100, 200, 50) (39 bits total)

Default: intBitCount=13, fracBitCount=9

[x: 13 bits integer + 9 bits fraction][y: 13+9 bits][z: 13+9 bits]
= 66 bits (8.25 bytes)
vs 96 bits (12 bytes) for 3 raw floats

NetId + UInt8 (32 + 8 bits)

[netId: 32 bits][value: 8 bits]

Enum EArenaMessage (3 bits)

EArenaMessage.PLAY (value 2):

[bits: 011]

NetId + Vector3 + Quaternion (32 + 66 + 32 = 130 bits = 16.25 bytes)

[netId: 32 bits][clampedVector3: 66 bits][compressedQuaternion: 32 bits]

RPC Invoke Method (message header + method index)

[EClientMessage.InvokeMethod: 5 bits][methodIndex: clientMethodsBitCount bits]
= 5 + 8 = 13 bits (for 200 methods)

Bit Alignment and Padding

The NetPak reader/writer operates on arbitrary bit boundaries — there is no byte alignment requirement between writes. This means a WriteBit followed by WriteUInt8 packs into 9 bits, not 16. However, the legacy bridge NetPakBlockImplementation calls writer.Flush() before returning bytes, which pads to the nearest byte boundary.

The AlignToByte() method on the reader adds padding bits to reach the next byte boundary:

csharp
public void AlignToByte()
{
    int bitsIntoByte = writeBitIndex & 7;
    if (bitsIntoByte != 0)
    {
        int padding = 8 - bitsIntoByte;
        for (int i = 0; i < padding; i++)
            WriteBit(false);
    }
}

This is used by the legacy message bridge (UPDATE_RELIABLE_BUFFER / UPDATE_UNRELIABLE_BUFFER) to ensure the legacy byte-oriented reader starts on a clean boundary.

Buffer Management

The NetPakWriter uses a pre-allocated buffer from Block.buffer (a 64KB byte[] shared across the networking system). The writer tracks:

  • buffer — the raw byte array.
  • writeByteIndex — current byte position.
  • writeBitIndex — current bit position within the current byte (0-7).

The Flush() method ensures all pending bits are written to the buffer:

csharp
public void Flush()
{
    if (writeBitIndex > 0)
    {
        // Pad remaining bits to full byte
        writeBitIndex = 0;
        writeByteIndex++;
    }
}

After flush, the data is ready for transport: buffer[0..writeByteIndex] contains the complete serialized message.

Reader/Writer Pooling

The NetPakReader and NetPakWriter are static singletons shared across the entire networking system:

csharp
static NetMessages()
{
    reader = new NetPakReader();
    writer = new NetPakWriter();
    writer.buffer = Block.buffer;
}

This pool pattern eliminates per-message allocation. The writer is reset before each use via Reset(), which sets writeByteIndex = 0 and writeBitIndex = 0 without zeroing the buffer (previous data is overwritten on write). The reader is similarly reset and bound to the received buffer segment.

Comparison with Block Class

The old Block class used byte-oriented serialization with BinaryWriter / BinaryReader:

FeatureBlock (old)NetPak (new)
UnitBytesBits
IntegersFixed 1/2/4/8 bytesVariable bit-width
Vector33×4 bytes = 96 bits66 bits (default)
Quaternion4×4 bytes = 128 bits32 bits
Enum1 byte (cast to int)3-5 bits
String2-byte length + UTF-8Same
NetId4 bytes32 bits (same)
Error detectionNoneErrorFlags enum

NetPak saves approximately 30-50% on wire size for common game message patterns, at the cost of slightly more complex serialization code.

Writer Error Tracking

csharp
[Flags]
public enum EErrorFlags
{
    None = 0,
    BufferOverflow = 1,   // Writer exceeded buffer capacity
    InvalidBitCount = 2,  // Bit count exceeds data type range
    // ...
}

The writer tracks error flags via NetPakWriter.EErrorFlags. In development builds, errors cause the message to be logged. In release builds, the corrupted message is silently dropped. The reader tracks equivalent error states, checked after each message handler in development builds.

Usage in Practice

In actual networked methods, the NetPak reader/writer is used through the generated delegates:

csharp
// Generated write method (called on the sending side)
public static void Write_ReceiveLevelNumber(NetPakWriter writer, byte newLevelNumber)
{
    writer.WriteUInt8(newLevelNumber);
}

// Generated read method (called on the receiving side)
public static void Read_ReceiveLevelNumber(in ClientInvocationContext context)
{
    byte newLevelNumber;
    context.reader.ReadUInt8(out newLevelNumber);
    LevelManager.ReceiveLevelNumber(newLevelNumber);
}

The generated code doesn't use NetPakBlockImplementation — it calls the raw reader/writer methods directly for maximum efficiency. The bridge class is only used by legacy code that hasn't been regenerated yet.

For methods with complex types, the generator emits calls to the specialized readers:

csharp
// Generated write for a method with Vector3, Quaternion, and byte parameters
public static void Write_ExampleMethod(NetPakWriter writer, Vector3 position, Quaternion rotation, byte flags)
{
    writer.WriteClampedVector3(position);  // 66 bits
    writer.WriteQuaternion(rotation);      // 32 bits
    writer.WriteUInt8(flags);             // 8 bits
    // Total: 106 bits = 13.25 bytes
    // Raw floats: 3*32 + 4*32 + 8 = 232 bits = 29 bytes
    // Savings: 54%
}

Data Integrity

The NetPak system does not include checksums or CRC in the message format — integrity is handled at the transport layer:

  • SNS includes DTLS-level integrity checks.
  • SystemSockets relies on TCP checksums.
  • Loopback has no integrity checks (same process, same memory).

Error detection at the NetPak level is limited to the EErrorFlags (buffer overflow, invalid bit count), which are used for debugging and safety asserts, not for data integrity in transit.

Performance Characteristics

NetPak operations are CPU-efficient for game networking:

OperationCPU CostNotes
WriteBit~1-2 nsSingle bit shift + mask
WriteUInt8~2-3 nsByte write + possible flush
WriteInt32~3-5 ns4 sequential byte writes
WriteFloat~3-5 nsRaw IEEE 754 bytes
WriteClampedVector3~20-30 ns3× clamp + 3× fixed-point + 3× int write
WriteQuaternion~15-25 nsMax component find + 3× 10-bit write
WriteString~5-10 ns + UTF-8Length prefix + byte copy
Flush~1-2 nsPadding to next byte

All operations operate on a pre-allocated buffer (no allocations). The bit-level operations use simple bit shifts and masks in a tight loop, making them suitable for the game thread's per-frame budget.

The reader/writer do not perform bounds checking in release builds (except via the EErrorFlags path). This avoids branch mispredictions but means buffer overflow must be prevented by the caller (buffer size is verified at the NetMessages level).

Comparison: NetPak vs Protobuf vs MessagePack

FeatureNetPakProtocol BuffersMessagePack
Schema requiredYes (codegen)Yes (.proto)No
Bit packingYes (arbitrary bits)Varint onlyNo
Vector3 supportNative (clamped)No (custom)No (custom)
Quaternion supportNative (compressed)No (custom)No (custom)
Allocation per messageZero (pooled)Depends on implementationDepends
Code generationCustom (Unity)protocNone
Wire sizeVery compactCompactCompact
CPU costVery lowLowLow

NetPak is specialized for game networking: it prioritizes compact wire format with direct support for game-specific types (Vector3, Quaternion, SteamID, NetId) over schema flexibility. Protocol Buffers would be a better choice for cross-platform server communication where schema evolution matters.

Byte Order

NetPak uses little-endian byte order for all multi-byte integer and float types. This matches the x86 architecture of the target platforms (Windows, Linux, macOS) and avoids byte-swapping overhead. If cross-platform compatibility with big-endian systems were needed, the reader/writer would need endianness detection and swapping — currently not implemented because the game only targets little-endian platforms.

Buffer Management

The writer and reader use a single shared buffer (Block.buffer, 64KB) allocated at startup and reused for all messages. Key buffer operations:

  • Reset() — sets write/read position to 0 without clearing buffer data.
  • Flush() — writes any pending bits to the buffer (pads to byte boundary).
  • SetBuffer(byte[] buffer) — binds the reader/writer to a specific buffer.
  • SetBufferSegment(byte[] buffer, int length) — binds to a segment (for reading received data).

The shared buffer means serialization and deserialization are single-threaded — you cannot read and write simultaneously from different threads. This is acceptable because all networking runs on the Unity game thread.