Hytale Networking: Protocol Packets and Wire Serialization
A source-grounded tour of Hytale's packet protocol: the framed wire format, VarInt and fixed/variable block serialization, the PacketRegistry, and the client-server message taxonomy.
Every interaction between a Hytale client and server — a footstep, a block placed, an inventory shuffle, a whole chunk of terrain — crosses the wire as a packet. This article dissects the packet protocol exactly as it ships in the decompiled Hytale server 2026.03.26, under com.hypixel.hytale.protocol. If you are writing a proxy, a packet logger, an alternate client, or a server plugin that needs to understand what the engine is actually sending, this is the layer you are working against.
Hytale's protocol is deliberately low-level. There is no reflection-driven serializer and no schema interpreter at runtime: every packet is a hand-shaped (codegen-shaped, really) class with a serialize method that writes raw little-endian bytes and a static deserialize that reads them back at fixed byte offsets. The whole thing runs over QUIC streams through Netty, with four logical channels and an optional Zstd compression stage. Understanding three pieces — the framing, the field layout, and the registry — is enough to read any packet in the source.
#The Packet contract
Every packet implements the Packet interface in com.hypixel.hytale.protocol. It is intentionally tiny:
public interface Packet {
int getId(); // numeric packet ID, unique across the protocol
NetworkChannel getChannel(); // which QUIC stream this rides on
void serialize(ByteBuf var1);// write the payload (no frame header)
int computeSize(); // exact serialized payload size in bytes
}Two empty marker interfaces, ToClientPacket and ToServerPacket, both extend Packet. They carry no methods — their only job is to declare a packet's legal direction at the type level, so the server cannot accidentally hand a client-bound type to the receive path. A single class may implement both (the registry calls that direction Both), but most implement exactly one. Ping, for example, is class Ping implements Packet, ToClientPacket.
NetworkChannel is the enum that decides which QUIC stream a packet uses, and it matters for ordering and head-of-line behavior:
| Channel | Value | Typical traffic |
|---|---|---|
Default | 0 | Almost everything: movement, inventory, interactions, entity updates |
Chunks | 1 | Terrain streaming (SetChunk, heightmaps, fluids) |
WorldMap | 2 | World map tiles and markers |
Voice | 3 | Voice chat frames |
Splitting chunk and voice traffic onto their own channels keeps a flood of terrain data from stalling the latency-sensitive Default stream.
#The wire frame
On the wire, each packet is a length-prefixed frame. The framing lives in PacketIO.writeFramedPacket and PacketIO.readFramedPacketWithInfo, and the layout is fixed:
+----------------------+----------------------+--------------------------+
| payload length (4 B) | packet ID (4 B) | payload (N bytes) |
| int32 LE | int32 LE | (maybe Zstd-compressed) |
+----------------------+----------------------+--------------------------+PacketIO.FRAME_HEADER_SIZE is 4; the Netty decoder enforces a minimum frame of 8 bytes (the two int32 headers) before it will attempt to read. Both header integers are little-endian — a theme you will see everywhere in this protocol. The maximum payload length the decoder accepts is 1677721600 bytes (~1.6 GB ceiling, though per-packet maxSize is almost always far smaller).
The encode side is a clean state machine. PacketIO.writeFramedPacket reserves the 4-byte length slot, writes the ID, serializes the payload into a scratch buffer, then either compresses it (if the packet is flagged compressed) or copies it straight through, and finally back-patches the real length with out.setIntLE(lengthIndex, ...):
int lengthIndex = out.writerIndex();
out.writeIntLE(0); // placeholder length, patched later
out.writeIntLE(id); // packet ID
ByteBuf payloadBuf = Unpooled.buffer(Math.min(info.maxSize(), 65536));
packet.serialize(payloadBuf);
int serializedSize = payloadBuf.readableBytes();
if (serializedSize > info.maxSize()) {
throw new ProtocolException("Packet " + info.name() + " ... exceeds max size");
}
// ... compress or copy, then:
out.setIntLE(lengthIndex, finalPayloadSize);Compression is per-packet, not per-stream
Whether a payload is Zstd-compressed is a static property of the packet type (IS_COMPRESSED), recorded in the registry as info.compressed(). There is no compression flag on the wire — the receiver knows from the packet ID whether to decompress. Compressed bulk packets include SetChunk, UpdatePlayerInventory, EntityUpdates, and WorldSettings; small high-frequency packets like Ping and ClientMovement are sent raw.
#VarInt and the IO primitives
Variable-length integers are the workhorse for lengths and counts. Hytale's VarInt is the familiar 7-bits-per-byte, high-bit-continuation LEB128 variant, capped at 5 bytes and unsigned only — VarInt.write throws on negative values:
public static void write(ByteBuf buf, int value) {
if (value < 0) throw new IllegalArgumentException("VarInt cannot encode negative values");
while ((value & -128) != 0) { // while more than 7 bits remain
buf.writeByte(value & 127 | 128); // low 7 bits + continuation flag
value >>>= 7;
}
buf.writeByte(value); // final byte, no continuation flag
}VarInt also exposes non-consuming helpers used heavily by the deserializers: VarInt.peek(buf, index) reads a value without advancing, VarInt.length(buf, index) returns how many bytes the encoded value occupies, and VarInt.size(int) returns how many bytes a value would take (1–5). Those let a parser compute field positions without mutating the reader index.
The static PacketIO class holds the rest of the codec library. Everything multi-byte is little-endian:
| Method | Reads / writes |
|---|---|
readUUID / writeUUID | 16-byte UUID (two big-endian longs) |
readVector3f / writeVector3f | three floatLE (JOML Vector3f) |
readQuaternionf / writeQuaternionf | four floatLE |
readMatrix4f / writeMatrix4f | sixteen floatLE, column-major |
readHalfLE / writeHalfLE | IEEE 754 half-float (16-bit) |
readVarString / writeVarString | VarInt byte length + UTF-8 bytes |
readVarAsciiString / writeVarAsciiString | VarInt byte length + ASCII bytes |
readFixedString / writeFixedString | fixed-width, zero-padded UTF-8 |
readFixedAsciiString / writeFixedAsciiString | fixed-width, zero-padded ASCII |
PacketIO.stringSize(s) returns VarInt.size(len) + len — the exact on-wire cost of a variable string, which is what computeSize() implementations sum up.
#Field layout: the null-bit + fixed + variable block
This is the part worth internalizing, because it is identical across nearly every packet. A serialized payload has up to three regions, advertised as public static final constants on each packet class:
- Nullable bit field (
NULLABLE_BIT_FIELD_SIZEbytes). A bitmask where each bit marks whether one nullable field is present. With two nullable bytes you get 16 flags. - Fixed block (
FIXED_BLOCK_SIZEbytes). Every fixed-width field, written at a constant byte offset. Crucially, absent nullable fields still occupy their slot, zero-filled — so the fixed block is always the same size and offsets never shift. - Variable block (starts at
VARIABLE_BLOCK_START). Strings, byte arrays, and lists, preceded inside the fixed block byVARIABLE_FIELD_COUNTint32 offset slots that point into this region.
Look at Ping (packet ID 3), the cleanest example. It declares NULLABLE_BIT_FIELD_SIZE = 1, FIXED_BLOCK_SIZE = 29, VARIABLE_FIELD_COUNT = 0, MAX_SIZE = 29:
@Override
public void serialize(ByteBuf buf) {
byte nullBits = 0;
if (this.time != null) nullBits = (byte)(nullBits | 1); // bit 0 = time present
buf.writeByte(nullBits);
buf.writeIntLE(this.id);
if (this.time != null) this.time.serialize(buf);
else buf.writeZero(12); // InstantData is 12 bytes; pad if absent
buf.writeIntLE(this.lastPingValueRaw);
buf.writeIntLE(this.lastPingValueDirect);
buf.writeIntLE(this.lastPingValueTick);
}Because absent fields are padded, deserialize is pure offset arithmetic — no cursor, just buf.getIntLE(offset + k) at compile-time-known positions:
public static Ping deserialize(ByteBuf buf, int offset) {
Ping obj = new Ping();
byte nullBits = buf.getByte(offset);
obj.id = buf.getIntLE(offset + 1);
if ((nullBits & 1) != 0) obj.time = InstantData.deserialize(buf, offset + 5);
obj.lastPingValueRaw = buf.getIntLE(offset + 17);
obj.lastPingValueDirect = buf.getIntLE(offset + 21);
obj.lastPingValueTick = buf.getIntLE(offset + 25);
return obj;
}ClientMovement (ID 108, the player's per-tick movement report) scales this up: two null-bytes covering nine nullable sub-structs (movementStates, relativePosition, absolutePosition, bodyOrientation, lookOrientation, teleportAck, wishMovement, velocity, riderMovementStates) plus one plain int mountedTo, all inside a constant 155-byte fixed block. Same pattern, more bits.
#Variable-length fields use offset slots
When a packet has strings or arrays, each gets a 4-byte offset slot in the fixed block holding the distance from VARIABLE_BLOCK_START to that field's bytes; a slot value of -1 means the field is absent. Connect (ID 0, the first packet a client sends) is the canonical example — VARIABLE_FIELD_COUNT = 5, VARIABLE_BLOCK_START = 66:
// inside Connect.serialize, after the fixed scalars:
int usernameOffsetSlot = buf.writerIndex(); buf.writeIntLE(0); // 5 reserved
// ... three more slots ...
int varBlockStart = buf.writerIndex();
buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varBlockStart);
PacketIO.writeVarAsciiString(buf, this.username, 16); // max 16 bytes
if (this.identityToken != null) {
buf.setIntLE(identityTokenOffsetSlot, buf.writerIndex() - varBlockStart);
PacketIO.writeVarString(buf, this.identityToken, 8192);
} else {
buf.setIntLE(identityTokenOffsetSlot, -1); // -1 = absent
}The reader inverts it: int pos = offset + 66 + buf.getIntLE(offset + 46) jumps to the username, then VarInt.peek reads its length. Every variable read is bounds- and length-checked against a per-field maximum (username ≤ 16, identityToken ≤ 8192, referralData ≤ 4096), throwing a typed ProtocolException — stringTooLong, arrayTooLong, negativeLength, bufferTooSmall — on violation. This validation-first design is what makes the parser safe against hostile input.
#The PacketRegistry
PacketRegistry is the single source of truth that maps IDs to behavior. Its static initializer calls register(...) once per packet, building three maps: TO_SERVER_BY_ID, TO_CLIENT_BY_ID, and a combined BY_ID. Each entry is a PacketInfo record:
public record PacketInfo(
int id, String name, NetworkChannel channel, Class<? extends Packet> type,
int fixedBlockSize, int maxSize, boolean compressed,
BiFunction<ByteBuf, Integer, ValidationResult> validate,
BiFunction<ByteBuf, Integer, Packet> deserialize
) {}The validate and deserialize fields are method references — Ping::validateStructure, Ping::deserialize — so dispatch is a map lookup plus a BiFunction.apply, with zero reflection. Registration is keyed by a PacketDirection (ToServer, ToClient, or Both) and register throws IllegalStateException on a duplicate ID, so the ID space is guaranteed collision-free at class-load time. Lookups are getToServerPacketById(id), getToClientPacketById(id), and getId(Class<? extends Packet>).
#The client-server message taxonomy
Packets are grouped into packages under protocol/packets/ by domain. A representative slice, with real IDs, directions, and channels pulled from the registry:
| Packet | ID | Direction | Channel | Category |
|---|---|---|---|---|
Connect | 0 | ToServer | Default | connection / handshake |
ClientDisconnect | 1 | ToServer | Default | connection |
ServerDisconnect | 2 | ToClient | Default | connection |
Ping | 3 | ToClient | Default | connection / keepalive |
Pong | 4 | ToServer | Default | connection / keepalive |
WorldSettings | — | ToClient | Default | setup |
ServerTags | — | ToClient | Default | setup |
JoinWorld | — | ToClient | Default | player lifecycle |
ClientReady | — | ToServer | Default | player lifecycle |
ClientMovement | 108 | ToServer | Default | player |
ClientPlaceBlock | — | ToServer | Default | player |
ClientTeleport | — | ToClient | Default | player |
SetGameMode | — | ToClient | Default | player |
EntityUpdates | 161 | ToClient | Default | entities |
PlayAnimation | 162 | ToClient | Default | entities |
MoveItemStack | — | ToServer | Default | inventory |
InventoryAction | — | ToServer | Default | inventory |
UpdatePlayerInventory | 170 | ToClient | Default | inventory |
PlayInteractionFor | — | ToClient | Default | interaction |
MountNPC | — | ToClient | Default | interaction |
SetChunk | — | ToClient | Chunks | world streaming |
Reading the directions tells you the protocol's shape. The client sends intent — Connect, ClientMovement, ClientPlaceBlock, InventoryAction, Pong. The server sends authoritative state — EntityUpdates, UpdatePlayerInventory, SetGameMode, ClientTeleport, SetChunk. Note that MountNPC is ToClient: the server is the authority on mounting, so it instructs the client, rather than the client asserting it mounted. That client-proposes / server-confirms asymmetry is the backbone of an anti-cheat-friendly authoritative server.
#The Netty pipeline and connection safety
Inbound bytes hit PacketDecoder (a ByteToMessageDecoder). It reads the length and ID, looks up the PacketInfo, and bails the whole connection on anything suspicious: an unknown ID, a payloadLength exceeding the packet's maxSize, or a packet arriving on the wrong NetworkChannel (checked against the STREAM_CHANNEL_KEY channel attribute). Each rejection calls ProtocolUtil.closeConnection, which on a QUIC stream issues a PROTOCOL_VIOLATION. The decoder also runs a 1-second-interval idle check against the PACKET_TIMEOUT_KEY attribute and fires a ReadTimeoutException when a peer goes quiet.
Outbound, PacketEncoder (a @Sharable MessageToByteEncoder<Packet>) verifies the packet's channel matches the stream's, then delegates to PacketIO.writeFramedPacket. Two optimizations live here. CachedPacket<T> wraps an already-serialized ByteBuf so a fan-out packet (one chunk to fifty players) is serialized once and blitted many times. And PacketStatsRecorder (with a NOOP default) records per-ID sent/received counts and compressed-vs-uncompressed byte totals — the hook a server uses to surface bandwidth telemetry.
Don't hand-roll frames
If you build tooling against this protocol, always go through PacketIO/PacketRegistry rather than emitting bytes yourself. The fixed-block padding, offset-slot bookkeeping, per-field max-length checks, and compression flag are easy to get subtly wrong, and the decoder will silently kill the connection with a PROTOCOL_VIOLATION instead of telling you which byte was bad.
#Putting it together
To trace or implement a packet end to end: identify its class under protocol/packets/<category>, read the PACKET_ID, IS_COMPRESSED, and the three block-size constants, then follow serialize/deserialize to map every byte. To go the other way — bytes to meaning — read the 4-byte LE length and 4-byte LE ID off the frame, resolve it through PacketRegistry.getToServerPacketById or getToClientPacketById, and let PacketInfo.deserialize() do the decoding. The registry is the dictionary; PacketIO is the grammar.
Modder checklist: working with Hytale packets
- Locate the packet class under
com.hypixel.hytale.protocol.packets.<category>and confirm whether it implementsToClientPacket,ToServerPacket, or both. - Read its
PACKET_ID,IS_COMPRESSED,NULLABLE_BIT_FIELD_SIZE,FIXED_BLOCK_SIZE,VARIABLE_FIELD_COUNT, andMAX_SIZEconstants before reading any logic. - Map the nullable bit field first: each bit gates one optional field, and absent fields are still zero-padded in the fixed block.
- For variable fields, find the 4-byte offset slot in the fixed block;
-1means absent, otherwise it is the distance fromVARIABLE_BLOCK_START. - Treat all multi-byte integers and floats as little-endian; use
PacketIOandVarInthelpers, never raw byte writes. - Respect each field's max-length constant — the deserializer throws a typed
ProtocolExceptionand the decoder closes the connection on overflow. - Send on the correct
NetworkChannel(chunks onChunks, voice onVoice); a channel mismatch is a protocol violation. - For repeated fan-out sends, wrap the packet in
CachedPacket.cache(...)to serialize once and reuse.
Build it yourself
Turn an idea into a working Hytale mod
Describe what you want in plain English and get a real, installable build. No boilerplate, no setup.
Start building