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);
// ... etcAll 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:
clientMethodsBitCountbits for client methods inClientMessageHandler_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:
- Each component is clamped to
[-4096, 4096)by default (13-bit integer range:[-2^12, 2^12)). - Each component is multiplied by
2^fracBitCountand rounded to an integer. - The integer is written as a signed integer using
intBitCountbits.
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:
- Find the index of the largest component (absolute value).
- Encode the index as 2 bits (
[0, 1, 2, 3]for[x, y, z, w]). - Encode the remaining three components as signed 10-bit integers in range
[-1, 1], scaled by√2/2for 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:
- A 16-bit unsigned integer length prefix (max 65535 bytes).
- 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:
- Determines the underlying integer type (byte, ushort, int, etc.).
- Determines the bit count needed to represent the full enum range (
CountBits(maxValue + 1)). - Writes the enum's integer value as a
UIntBitswith 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 viaReadString.bool[]: ushort length prefix + each asReadBit.byte[]: byte length prefix +ReadBytes.int[]: ushort length prefix + each asReadInt32.ulong[]: ushort length prefix + each asReadUInt64.
Length types vary by expected array size:
- Small arrays (strings, bytes):
bytelength (max 255). - Large arrays (ints, ulongs, bools):
ushortlength (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 floatsNetId + 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:
| Feature | Block (old) | NetPak (new) |
|---|---|---|
| Unit | Bytes | Bits |
| Integers | Fixed 1/2/4/8 bytes | Variable bit-width |
| Vector3 | 3×4 bytes = 96 bits | 66 bits (default) |
| Quaternion | 4×4 bytes = 128 bits | 32 bits |
| Enum | 1 byte (cast to int) | 3-5 bits |
| String | 2-byte length + UTF-8 | Same |
| NetId | 4 bytes | 32 bits (same) |
| Error detection | None | ErrorFlags 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:
| Operation | CPU Cost | Notes |
|---|---|---|
| WriteBit | ~1-2 ns | Single bit shift + mask |
| WriteUInt8 | ~2-3 ns | Byte write + possible flush |
| WriteInt32 | ~3-5 ns | 4 sequential byte writes |
| WriteFloat | ~3-5 ns | Raw IEEE 754 bytes |
| WriteClampedVector3 | ~20-30 ns | 3× clamp + 3× fixed-point + 3× int write |
| WriteQuaternion | ~15-25 ns | Max component find + 3× 10-bit write |
| WriteString | ~5-10 ns + UTF-8 | Length prefix + byte copy |
| Flush | ~1-2 ns | Padding 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
| Feature | NetPak | Protocol Buffers | MessagePack |
|---|---|---|---|
| Schema required | Yes (codegen) | Yes (.proto) | No |
| Bit packing | Yes (arbitrary bits) | Varint only | No |
| Vector3 support | Native (clamped) | No (custom) | No (custom) |
| Quaternion support | Native (compressed) | No (custom) | No (custom) |
| Allocation per message | Zero (pooled) | Depends on implementation | Depends |
| Code generation | Custom (Unity) | protoc | None |
| Wire size | Very compact | Compact | Compact |
| CPU cost | Very low | Low | Low |
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.
