8 topical docs (overview, data types, lifecycle, handshake, status/ping, login+encryption, configuration, version-differences) + proxy-forwarding set + 16 per-version release-line docs, sourced from minecraft.wiki, ViaVersion (source + commits), minecraft-data, node-minecraft-protocol, Velocity, BungeeCord. 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).
- Plugin message unrecognised-channel data: vanilla client caps at 1,048,576 bytes.
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 for server-transfer support.
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.).