Corrected real errors: several 1.7.x release dates, resource_pack_send version, config packet ordering, structured-component count (56), PLAYER_LOADED (1.21.4), entity_sound_effect field order. Confirmed+cited the rest; remaining ~19 items re-marked UNCONFIRMED (third-party/ViaLegacy/26.2 internals unreachable from refs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
Minecraft Java Edition Protocol — Overview
Scope: Java Edition only. Protocol details change with each release; version-specific differences are called out explicitly. "Latest" means 1.21.x as of mid-2026 unless noted.
Primary sources:
node-minecraft-protocolv1.66.2 (/tmp/mcproto-refs/node-minecraft-protocol/)- minecraft.wiki/w/Java_Edition_protocol (formerly wiki.vg) — Packet format / Data types
1. Transport
- TCP, no UDP (voice-chat plugins add their own UDP layer out-of-band).
- Default port: 25565 (configurable).
- All data is big-endian except VarInt/VarLong, which use little-endian 7-bit groups (see
01-data-types.md). - Encryption is applied after the Login handshake (online-mode servers). The cipher is AES-128-CFB8, using the shared secret as both key and IV. Encryption wraps the raw TCP byte stream; packet framing runs on top of the encrypted layer.
- Source:
node-minecraft-protocol/src/transforms/encryption.js:6-9(aes-128-cfb8, key=IV=shared secret).
- Source:
2. Packet Framing
Every packet on the wire is length-prefixed. There are two modes: uncompressed (before the server sends Set Compression) and compressed (after).
2.1 Uncompressed packet format
| Field | Type | Notes |
|---|---|---|
| Length | VarInt | Byte count of everything that follows (Packet ID + Data). |
| Packet ID | VarInt | State- and direction-scoped identifier. |
| Data | bytes | Payload; structure defined per packet. |
The Framer prepends the length:
// node-minecraft-protocol/src/transforms/framing.js:16-21
const varIntSize = sizeOfVarInt(chunk.length)
const buffer = Buffer.alloc(varIntSize + chunk.length)
writeVarInt(chunk.length, buffer, 0)
chunk.copy(buffer, varIntSize)
The Splitter reads length, then slices exactly that many bytes — buffering partial reads across TCP segments (framing.js:48-73).
2.2 Compressed packet format
Enabled by the serverbound Set Compression packet (Login state). After that, every subsequent packet uses:
| Field | Type | Notes |
|---|---|---|
| Packet Length | VarInt | Byte count of Data Length field + compressed payload. |
| Data Length | VarInt | Uncompressed size of (Packet ID + Data). 0 = not compressed. |
| Packet ID | VarInt | } zlib-deflated together when Data Length > 0 |
| Data | bytes | } |
Threshold rule: if len(Packet ID + Data) >= compressionThreshold, compress and set Data Length to the uncompressed size. Otherwise send uncompressed with Data Length = 0.
// node-minecraft-protocol/src/transforms/compression.js:22-40
if (chunk.length >= this.compressionThreshold) {
const newChunk = zlib.deflateSync(chunk)
// prepend VarInt(chunk.length), then compressed bytes
} else {
// prepend VarInt(0), then raw bytes
}
Historical note: Compression was added in 1.8 (protocol 47). Per server/login.js:179, node-minecraft-protocol gates compression on protocolVersion >= 27 (snapshot 14w28a), which predates the 1.8 release — whole-protocol compression replaced earlier per-packet compression in that snapshot.
2.3 Packet size limits
- Uncompressed maximum: 2²¹ − 1 = 2,097,151 bytes (the
LengthVarInt may not exceed 3 bytes on the wire). — minecraft.wiki - Serverbound compressed: uncompressed (Packet ID + Data) must be ≤ 2²³ bytes (8,388,608). Source: minecraft.wiki/w/Java_Edition_protocol/Packets — "For serverbound packets, the uncompressed length of (Packet ID + Data) must not be greater than 2^23 or 8388608 bytes." (node-minecraft-protocol's decompressor does not enforce this cap; Velocity's
MinecraftCompressDecodercaps serverbound at 2 MiB for security, vanilla at 8 MiB clientbound —MinecraftCompressDecoder.java:39-40.) - Plugin message unrecognised-channel data: vanilla client caps at 1,048,576 bytes. Source: minecraft.wiki/w/Java_Edition_protocol/Packets; corroborated by Velocity
PluginMessagePacket.java:65(MAX_PAYLOAD_SIZE_CLIENTBOUND = 1048576, comment: "the vanilla expected limit").
3. Connection State Machine
The protocol is divided into states. Each state has its own independent set of packet IDs. Transitions are triggered by specific packets.
// node-minecraft-protocol/src/states.js:3-8
const states = {
HANDSHAKING: 'handshaking',
STATUS: 'status',
LOGIN: 'login',
CONFIGURATION: 'configuration', // added in 1.20.2 (protocol 764)
PLAY: 'play'
}
State diagram
stateDiagram-v2
[*] --> Handshaking : "TCP connect"
Handshaking --> Status : "Handshake (nextState=1)"
Handshaking --> Login : "Handshake (nextState=2)"
Handshaking --> Login : "Handshake (nextState=3, Transfer 1.20.5+)"
Status --> [*] : "Status Response + Ping/Pong"
Login --> Configuration : "Login Success → Login Acknowledged (1.20.2+)"
Login --> Play : "Login Success (pre-1.20.2)"
Configuration --> Play : "Finish Configuration (both sides)"
Play --> Configuration : "Start Configuration (server-initiated, 1.20.2+)"
Play --> [*] : "Disconnect"
3.1 Handshaking
Initial state on connect. Client sends exactly one packet:
- Handshake (0x00, serverbound): carries
protocolVersion,serverHost,serverPort, andnextState(1 = Status, 2 = Login, 3 = Transfer).
After this packet the server transitions immediately to the indicated next state.
// node-minecraft-protocol/src/server/handshake.js:34-38
if (packet.nextState === 1) {
client.state = states.STATUS
} else if (packet.nextState === 2) {
client.state = states.LOGIN
}
nextState = 3 (Transfer) was added in 1.20.5 (protocol 766) for server-transfer support. Source: Velocity HandshakeIntent.java:16 (TRANSFER(3)); ProtocolVersion.java:89 (MINECRAFT_1_20_5(766, "1.20.5", "1.20.6")); ViaVersion InitialBaseProtocol.java:55,133 (TRANSFER_INTENT = 3, rejected for servers olderThan(ProtocolVersion.v1_20_5)).
3.2 Status
Server-list ping. Two round-trips:
- Client → Status Request (0x00)
- Server → Status Response (0x00) — JSON payload with MOTD, player count, favicon
- Client → Ping Request (0x01) — 8-byte timestamp
- Server → Pong Response (0x01) — echoes timestamp
Connection is then closed by the client. The server does not transition to another state.
3.3 Login
Authentication and encryption negotiation.
Typical online-mode sequence:
- C→S:
Login Start(username, UUID, optional profile-key signature) - S→C:
Encryption Request(server ID, RSA public key, verify token) - C→S:
Encryption Response(RSA-encrypted shared secret + verify token) - (Both sides enable AES-128-CFB8 encryption)
- Server calls Mojang session-server to verify join
- S→C:
Set Compression(threshold; gates compressed mode) — added in 1.8 / snapshot 14w28a - S→C:
Login Success(UUID, username, properties) - C→S:
Login Acknowledged— triggers transition to Configuration (1.20.2+)
// node-minecraft-protocol/src/server/login.js:179
if (client.protocolVersion >= 27) {
client.write('compress', { threshold: 256 })
client.compressionThreshold = 256
}
// node-minecraft-protocol/src/server/login.js:189-193
if (client.supportFeature('hasConfigurationState')) {
client.once('login_acknowledged', onClientLoginAck)
} else {
client.state = states.PLAY
}
3.4 Configuration (1.20.2+, protocol 764+)
Introduced in 1.20.2. Entered after Login Acknowledged; used for:
- Registry codec data (dimension types, biomes, etc.)
- Feature flags
- Known packs negotiation
- Server brand plugin channel
Both client and server exchange Finish Configuration to exit this state.
// node-minecraft-protocol/src/server/login.js:224-238 (onClientLoginAck)
client.state = states.CONFIGURATION
// ... send registry_data, then finish_configuration
client.once('finish_configuration', () => {
client.state = states.PLAY
})
The server can re-enter Configuration from Play at any time by sending Start Configuration (serverbound: Configuration Acknowledged from client acknowledges the switch back).
// node-minecraft-protocol/src/client/play.js:43-44
client.on('start_configuration', () => enterConfigState())
// ...
if (client.state === states.PLAY) {
client.write('configuration_acknowledged', {})
}
3.5 Play
Normal gameplay state. The bulk of all packets are Play-state packets. This state persists until disconnect or a server-initiated re-entry into Configuration.
4. Packet ID Scoping
Packet IDs are not globally unique. They are scoped by both state and direction:
- State: Handshaking / Status / Login / Configuration / Play
- Direction: serverbound (client → server) or clientbound (server → client)
A packet ID of 0x00 in Handshaking-serverbound (Handshake packet) is completely unrelated to 0x00 in Status-clientbound (Status Response), and so on. You need all three pieces — state, direction, ID — to unambiguously identify a packet.
The serializer key in node-minecraft-protocol reflects this:
// node-minecraft-protocol/src/transforms/serializer.js:47-51
function createSerializer ({ state = states.HANDSHAKING, isServer = false, version, ... }) {
return new Serializer(
createProtocol(state, !isServer ? 'toServer' : 'toClient', version, ...),
'packet'
)
}
Concrete packet ID assignments live in minecraft-data (a separate package) and change between protocol versions. Always consult the version-specific protocol data, not hard-coded constants.
5. Encryption Layer Position
The pipeline (innermost to outermost on send) is:
Packet (ID + payload)
→ Compression (optional, zlib deflate)
→ Framing (VarInt length prefix)
→ Encryption (AES-128-CFB8, wraps the byte stream after framing)
→ TCP
Framing runs before encryption so the receiver can split frames while decrypting. Both directions use the same shared secret but independent cipher/decipher instances.
Source: node-minecraft-protocol/src/transforms/encryption.js — AES-128-CFB8 with key = IV = sharedSecret.
6. Legacy Server List Ping
Clients predating the current framing (pre-1.7) send a 0xFE byte as the first byte of the TCP stream. node-minecraft-protocol detects this and shims it into the modern VarInt-framed pipeline:
// node-minecraft-protocol/src/transforms/framing.js:36-43
if (this.recognizeLegacyPing && this.buffer[0] === LEGACY_PING_PACKET_ID) {
// Prefix a VarInt-encoded packet ID so the deserializer can handle it
}
This is a compatibility shim only; the modern protocol framing is always VarInt-prefixed as described in §2.
See 01-data-types.md for wire type encodings (VarInt, Position, NBT, etc.).