Files
minecraft_protocol/00-overview.md
T
claude-timemachine a3d5f64ef5 verify pass: resolve VERIFY flags (corrections + citations + honest UNCONFIRMED)
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>
2026-06-19 15:03:44 +02:00

11 KiB
Raw Blame History

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-protocol v1.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).

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 Length VarInt 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 MinecraftCompressDecoder caps 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, and nextState (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:

  1. Client → Status Request (0x00)
  2. Server → Status Response (0x00) — JSON payload with MOTD, player count, favicon
  3. Client → Ping Request (0x01) — 8-byte timestamp
  4. 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:

  1. C→S: Login Start (username, UUID, optional profile-key signature)
  2. S→C: Encryption Request (server ID, RSA public key, verify token)
  3. C→S: Encryption Response (RSA-encrypted shared secret + verify token)
  4. (Both sides enable AES-128-CFB8 encryption)
  5. Server calls Mojang session-server to verify join
  6. S→C: Set Compression (threshold; gates compressed mode) — added in 1.8 / snapshot 14w28a
  7. S→C: Login Success (UUID, username, properties)
  8. 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.).