Skip to content

Packet Structure

Hytale uses a custom binary protocol built on top of Netty. All packets are framed with a length prefix and use little-endian byte order for multi-byte integers.

Every packet is wrapped in a frame with the following structure:

┌─────────────────────────────────────────────────────────────┐
│ Payload Length (4 bytes, LE) │ Packet ID (4 bytes, LE) │
├─────────────────────────────────────────────────────────────┤
│ Payload (N bytes) │
│ (optionally Zstd compressed) │
└─────────────────────────────────────────────────────────────┘
FieldSizeDescription
Payload Length4 bytes (int32 LE)Size of payload in bytes
Packet ID4 bytes (int32 LE)Unique packet type identifier
PayloadVariablePacket data (may be compressed)

Limits:

  • Maximum payload size: 1,677,721,600 bytes (~1.6 GB)
  • Minimum frame size: 8 bytes (header only, empty payload)

Large packets can be automatically compressed using Zstd (Zstandard). Whether a packet type supports compression is defined in the packet registry.

Compressed packets work as follows:

  1. Serialize packet payload to buffer
  2. Compress with Zstd at configurable level (default: Zstd’s default level)
  3. Write compressed size as the “Payload Length” in frame header
  4. Receiver decompresses based on packet registry metadata

The compression level can be configured via system property:

-Dhytale.protocol.compressionLevel=3

Strings and arrays are prefixed with their length encoded as a VarInt. This encoding uses 7 bits per byte for data, with the high bit indicating continuation:

Value RangeBytes Used
0 - 1271
128 - 16,3832
16,384 - 2,097,1513
2,097,152 - 268,435,4554
268,435,456+5

Encoding example:

Value 300 = 0b100101100
Encoded as: [0xAC, 0x02]
- Byte 1: 0xAC = 0b10101100 (0b0101100 | 0x80 continuation flag)
- Byte 2: 0x02 = 0b00000010 (no continuation)
- Result: (0x02 << 7) | 0x2C = 256 + 44 = 300

Important: VarInt only encodes non-negative values. Negative values throw a ProtocolException.

All multi-byte primitives use little-endian byte order.

TypeSizeDescription
byte1Signed 8-bit integer
short2Signed 16-bit integer (LE)
int4Signed 32-bit integer (LE)
long8Signed 64-bit integer (LE)
float432-bit IEEE 754 float (LE)
half216-bit half-precision float (LE)

Strings come in two variants:

Variable-length string (VarString):

┌──────────────────┬──────────────────────────┐
│ Length (VarInt) │ UTF-8/ASCII bytes │
└──────────────────┴──────────────────────────┘

Fixed-length string:

┌──────────────────────────────────────────────┐
│ Bytes (N fixed) │ Null-padded if shorter │
└──────────────────────────────────────────────┘

Maximum string lengths are defined per-field. For example:

  • Username: max 16 bytes (ASCII)
  • Identity token: max 8,192 bytes (UTF-8)
  • Protocol hash: 64 bytes fixed (ASCII)

UUIDs are stored as two 64-bit values (big-endian within the UUID itself):

┌──────────────────────────┬──────────────────────────┐
│ Most Significant (8B) │ Least Significant (8B) │
└──────────────────────────┴──────────────────────────┘

Arrays are prefixed with their count as a VarInt:

┌──────────────────┬──────────────────────────────────┐
│ Count (VarInt) │ Elements (count × element size) │
└──────────────────┴──────────────────────────────────┘

Packets use a null bit field at the start to track which optional fields are present:

┌────────────────────┬───────────────────────────────────────┐
│ Null Bits (1-N B) │ Fixed fields, then variable fields │
└────────────────────┴───────────────────────────────────────┘

Each bit corresponds to an optional field:

  • Bit = 1: Field is present
  • Bit = 0: Field is null/absent

Packets are structured with fixed-size fields first, followed by variable-size fields:

┌─────────────────────────────────────────────────────────────┐
│ Null Bits (N bytes) │
├─────────────────────────────────────────────────────────────┤
│ Fixed-Size Block │
│ (primitives, enums, inline structs) │
├─────────────────────────────────────────────────────────────┤
│ Offset Table │
│ (4-byte LE offsets to variable fields) │
├─────────────────────────────────────────────────────────────┤
│ Variable Block │
│ (strings, arrays, nested objects) │
└─────────────────────────────────────────────────────────────┘

Variable fields are accessed by offset table entries that point into the variable block relative to its start.

The Connect packet (ID 0) demonstrates the full structure:

public class Connect implements Packet {
public String protocolHash; // fixed 64 bytes ASCII
public ClientType clientType; // 1 byte enum
public String language; // nullable VarString (max 128)
public String identityToken; // nullable VarString (max 8192)
public UUID uuid; // 16 bytes
public String username; // varString (max 16)
public byte[] referralData; // nullable byte array (max 4096)
public HostAddress referralSource; // nullable nested object
}

Wire format:

Offset Size Field
────── ──── ─────
0 1 Null bits (4 nullable fields)
1 64 protocolHash (fixed ASCII)
65 1 clientType (enum byte)
66 16 uuid
82 4 language offset (into variable block)
86 4 identityToken offset
90 4 username offset
94 4 referralData offset
98 4 referralSource offset
102 var Variable block start
... (actual string/array data)

Before deserializing a packet, the protocol validates its structure:

  • Buffer size meets minimum requirements
  • String/array lengths don’t exceed maximums
  • Offsets point within valid ranges
  • Nested objects validate recursively

Invalid packets cause the connection to be closed with no response.

Each packet type is registered with:

  • ID: Unique 32-bit identifier
  • Name: Human-readable name for debugging
  • Fixed block size: Minimum size of non-variable fields
  • Max size: Maximum total packet size
  • Compressed: Whether to use Zstd compression
  • Validator: Structure validation function
  • Deserializer: Function to parse bytes into object

See Packet Categories for the full ID range allocation.