d73c1c9537
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>
269 lines
11 KiB
Markdown
269 lines
11 KiB
Markdown
# 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](https://minecraft.wiki/w/Java_Edition_protocol) / [Data types](https://minecraft.wiki/w/Java_Edition_protocol/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:
|
||
|
||
```js
|
||
// 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`.
|
||
|
||
```js
|
||
// 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](https://minecraft.wiki/w/Java_Edition_protocol)
|
||
- Serverbound compressed: uncompressed (Packet ID + Data) must be ≤ 2²³ bytes (8,388,608). <!-- VERIFY: wiki states this limit; not reflected in node-minecraft-protocol source -->
|
||
- Plugin message unrecognised-channel data: vanilla client caps at 1,048,576 bytes. <!-- VERIFY -->
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
```js
|
||
// 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
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```js
|
||
// 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. <!-- VERIFY exact protocol number for Transfer -->
|
||
|
||
### 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+)
|
||
|
||
```js
|
||
// node-minecraft-protocol/src/server/login.js:179
|
||
if (client.protocolVersion >= 27) {
|
||
client.write('compress', { threshold: 256 })
|
||
client.compressionThreshold = 256
|
||
}
|
||
```
|
||
|
||
```js
|
||
// 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.
|
||
|
||
```js
|
||
// 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).
|
||
|
||
```js
|
||
// 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:
|
||
|
||
```js
|
||
// 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:
|
||
|
||
```js
|
||
// 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.).*
|