Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Protocol phases

Verified against Minecraft 26.2 · Part IX · A login: from clicking a server in the list to standing in the world.

Click a server in the multiplayer list and one TCP connection opens, and over the next second it speaks four different languages in turn. Each is a ConnectionProtocol; each has its own packet set, and all but handshaking a listener at both ends — handshaking is serverbound only, so the client has nothing to listen with; and each hands over to the next by a packet marked terminal, which tears its own codec out of the pipeline as it passes. What a 1.21-era reader will not expect is where the work happens. Every server-side handler in the handshake and login phases runs on the Netty event loop, and the thing that actually advances a login is a tick. And the ServerPlayer — the object, its save data, its position, the chunks under it — is prepared during configuration, by a task named for it, and constructed after the client has already acknowledged that configuration is over, by which point the server is encoding play packets to a player that does not yet exist.

The cast

classrolethread
ConnectionProtocolthe five phases — a bare enum of labels; the codec lives in a ProtocolInfo, the behaviour in a PacketListener
Connectionone channel, and Connection.setupInboundProtocol / Connection.setupOutboundProtocol at every transitionNetty
ServerHandshakePacketListenerImplthe three-way switch and the version gateNetty
ServerLoginPacketListenerImplthe login state machine; its handlers set a volatile state and its ServerLoginPacketListenerImpl.tick acts on itNetty, ticked from Server
ServerConfigurationPacketListenerImplthe serial task queue, and the handler that finally builds the playermixed — see below
ClientHandshakePacketListenerImplthe client’s side of handshake and login, including the session-service callNetty, client IO pool
ClientConfigurationPacketListenerImplaccumulates registries and tags, then constructs the ClientPacketListenerNetty, then Render
PrepareSpawnTaskfinds a spawn, tickets its chunks, waits — and later, on request, spawns the playerServer

The five phases

stateDiagram-v2
    direction LR
    [*] --> HANDSHAKING : TCP accept
    HANDSHAKING --> STATUS : ClientIntentionPacket, intent STATUS
    HANDSHAKING --> LOGIN : ClientIntentionPacket, intent LOGIN or TRANSFER
    STATUS --> [*] : pong, then the server hangs up
    LOGIN --> CONFIGURATION : ClientboundLoginFinishedPacket, ServerboundLoginAcknowledgedPacket
    CONFIGURATION --> PLAY : ClientboundFinishConfigurationPacket, ServerboundFinishConfigurationPacket
    PLAY --> CONFIGURATION : ClientboundStartConfigurationPacket, ServerboundConfigurationAcknowledgedPacket
    PLAY --> [*] : disconnect
    note right of HANDSHAKING : every transition packet is terminal, so the codec that decoded it is already gone

ConnectionProtocol is five constants — ConnectionProtocol.HANDSHAKING, ConnectionProtocol.STATUS, ConnectionProtocol.LOGIN, ConnectionProtocol.CONFIGURATION, ConnectionProtocol.PLAY — each carrying only a string ConnectionProtocol.id. There is no number, no packet table and no lookup by id. What a phase is lives in two other places: the packet set, bound as a ProtocolInfo, and the listener that handles it.

phaseserverbound listenerclientbound listenerProtocolInfo
ConnectionProtocol.HANDSHAKINGServerHandshakePacketListenerImpl, or MemoryServerHandshakePacketListenerImpl in singleplayernone — there is no clientbound handshake protocolHandshakeProtocols.SERVERBOUND, one packet
ConnectionProtocol.STATUSServerStatusPacketListenerImplreached from ServerStatusPingerStatusProtocols.SERVERBOUND binds a raw buffer; StatusProtocols.CLIENTBOUND a FriendlyByteBuf
ConnectionProtocol.LOGINServerLoginPacketListenerImplClientHandshakePacketListenerImplLoginProtocols.SERVERBOUND / LoginProtocols.CLIENTBOUND
ConnectionProtocol.CONFIGURATIONServerConfigurationPacketListenerImplClientConfigurationPacketListenerImplConfigurationProtocols.SERVERBOUND / ConfigurationProtocols.CLIENTBOUND
ConnectionProtocol.PLAYServerGamePacketListenerImplClientPacketListenernot pre-boundGameProtocols.SERVERBOUND_TEMPLATE and GameProtocols.CLIENTBOUND_TEMPLATE are bound per connection

The bindings in the last column are the per-phase codec tables that packets and stream codecs builds, and the swap itself is the pipeline surgery the connection performs; this page is what the swaps are for. The first four bind their buffers once, at class load, because they need no registries. Play cannot: its codecs write registry ids, so both templates are bound per connection with RegistryFriendlyByteBuf.decorator at the configuration-to-play switch. On the client that is genuinely the first moment a RegistryAccess exists; on the server the registries have been there since startup and the rebind is only because the protocol changed. The serverbound play template is also the one protocol with a context object, GameProtocols.Context, whose single question is GameProtocols.Context.hasInfiniteMaterials — which is why it is an UnboundProtocol where the other eight templates are a SimpleUnboundProtocol. Eight, not four: every phase but handshaking declares one per direction.

Two listeners sit under the phases. ServerCommonPacketListenerImpl is the shared base of the server’s configuration and play listeners and holds everything legal in both — keep-alive, latency, custom payloads, resource-pack responses and the flush suspension — with ClientCommonPacketListenerImpl its client counterpart; that inheritance is why the common packets in reference/packets.md belong to no single phase. And the state that crosses a phase change is a CommonListenerCookie: on the server a small record of profile, latency, client information and a transferred flag; on the client a much larger one carrying registries, feature flags, cookies, chat state and the server brand.

Handshake

The handshake is one packet and a three-way switch. ClientIntentionPacket carries the protocol version, the address the client dialled and a ClientIntent, and ServerHandshakePacketListenerImpl.handleIntention branches on it. ClientIntent.STATUS installs the status listener, or disconnects at once if the server does not reply to status. ClientIntent.TRANSFER disconnects if the server does not accept transfers, and otherwise joins ClientIntent.LOGIN in ServerHandshakePacketListenerImpl.beginLogin, which compares the client’s protocol version against this build’s and refuses a mismatch — outdated_client below the 1.16.4 protocol number, incompatible above it. The two refusals on the login path first install the login clientbound protocol, purely so they can send ClientboundLoginDisconnectPacket and have the client render a reason. The status refusal is the exception and the rudest: the status clientbound protocol is installed before the branch, and then the connection is dropped with no packet at all.

The client does not wait for any of that. Whichever of its three entry points opened the connection — ConnectScreen for a listed or direct server, Minecraft for the integrated server’s memory channel, RealmsConnect — it sends ServerboundHelloPacket immediately after the intention packet; there is no round trip between them. The profile id the hello carries is decoded and then never read — the server mints or fetches identity for itself. All of this runs on the Netty thread, and the intention packet, being terminal, has already torn out the codec that decoded it.

Status, the phase nobody logs in through

ConnectionProtocol.STATUS is two packets each way and a deliberate dead end. ServerStatusPacketListenerImpl answers exactly one ServerboundStatusRequestPacket — a second one disconnects the caller — and answers ServerboundPingRequestPacket with ClientboundPongResponsePacket and then hangs up. A status connection is expected to be thrown away, which is why ServerStatusPinger opens one per server in the list and why Connection.initiateServerboundStatusConnection exists as a separate entry point.

Login

stateDiagram-v2
    direction LR
    [*] --> HELLO
    HELLO --> KEY : online mode over a socket, ClientboundHelloPacket sent
    HELLO --> VERIFYING : singleplayer profile, or offline mode
    KEY --> AUTHENTICATING : ServerboundKeyPacket, ciphers installed now
    AUTHENTICATING --> VERIFYING : the User Authenticator thread stores the profile
    VERIFYING --> WAITING_FOR_DUPE_DISCONNECT : tick, a player with this profile is still in the world
    VERIFYING --> PROTOCOL_SWITCHING : tick, bans and whitelist pass, ClientboundLoginFinishedPacket sent
    WAITING_FOR_DUPE_DISCONNECT --> PROTOCOL_SWITCHING : tick, the old connection is gone
    PROTOCOL_SWITCHING --> ACCEPTED : ServerboundLoginAcknowledgedPacket, configuration begins
    NEGOTIATING : NEGOTIATING, declared and never assigned
    note left of VERIFYING : the three tick transitions are the server-thread work that advances the login

ServerLoginPacketListenerImpl has no thread hop anywhere, which is why its ServerLoginPacketListenerImpl.state field is volatile: the packet handlers run on the Netty thread and set the state, and ServerLoginPacketListenerImpl.tick — reached from MinecraftServer.tickConnection through ServerConnectionListener.tick and Connection.tick — reads it on the server thread and does the login. The tick is also where ServerLoginPacketListenerImpl.MAX_TICKS_BEFORE_LOGIN, six hundred ticks, is enforced: a client that has not reached the end of the phase in thirty seconds is disconnected for a slow login.

Three branches out of the hello. If the name matches the singleplayer profile, verification starts at once with no encryption. If the server uses authentication and this is not a memory connection, the state becomes ServerLoginPacketListenerImpl.State.KEY and ClientboundHelloPacket carries the server’s RSA public key and a four-byte challenge. Otherwise — offline mode — the profile is minted from the name by UUIDUtil.createOfflineProfile and nothing is encrypted.

Both sides authenticate, and the client goes first.

sequenceDiagram
    participant CHPL as ClientHandshakePacketListenerImpl
    participant SLPL as ServerLoginPacketListenerImpl
    participant Auth as User Authenticator thread

    SLPL->>CHPL: ClientboundHelloPacket, RSA public key and a four-byte challenge
    CHPL->>CHPL: generate the AES secret, digest over server id, secret and key
    CHPL->>Auth: joinServer on the client IO pool, before the key packet is sent
    CHPL->>SLPL: ServerboundKeyPacket, secret and challenge RSA-encrypted, ciphers attached to the send
    SLPL->>SLPL: validate the challenge, recover the secret, Connection.setEncryptionKey now
    SLPL->>Auth: hasJoinedServer on a fresh thread named for user authentication
    Auth-->>SLPL: the authenticated profile, state VERIFYING
    Note over SLPL: the next server tick runs bans, whitelist, compression, duplicates

The client generates the AES secret and computes a digest over the server id, the secret and the server’s public key; if the server asked for authentication it calls the session service on its IO pool before the key packet goes anywhere, and sends ServerboundKeyPacket from that callback, attaching its own ciphers to the send. The server validates the challenge, recovers the secret, recomputes the digest, installs its ciphers immediately and synchronously, and only then starts its own session-service call. An unauthenticated connection is already encrypted.

Authentication is a plain thread with two fallbacks. It calls the session service, reports login activity and on success stores the profile and flips the state to ServerLoginPacketListenerImpl.State.VERIFYING. On failure it disconnects — unless the server is a singleplayer host, in which case both a null result and an unreachable authentication service fall back to an offline profile. That is how a LAN world admits a guest whose account cannot be checked. A login over a real socket against an authenticating server starts exactly one such thread; an offline-mode, memory or singleplayer-profile login starts none.

The tick does the real login. ServerLoginPacketListenerImpl.verifyLoginAndFinishConnectionSetup runs on the server thread: PlayerList.canPlayerLogin for bans, whitelist and capacity; the compression switch; and PlayerList.disconnectAllPlayersWithProfile for a duplicate login, after which the machine waits in ServerLoginPacketListenerImpl.State.WAITING_FOR_DUPE_DISCONNECT until the old connection has actually died. It also compares the authenticated profile against Connection.getIntendedProfileId, which is set in exactly one place, ServerConnectionListener.acceptChannel — and nothing in the tree calls that, so it is an embedder’s hook.

Login ends with a terminal packet in each direction, and the two sides install their codecs in mirror order. ClientboundLoginFinishedPacket then ServerboundLoginAcknowledgedPacket — but the server installs outbound configuration when the acknowledgement arrives, in ServerLoginPacketListenerImpl.handleLoginAcknowledgement, whereas the client installs inbound configuration before sending it and outbound immediately after. Both packets are terminal, so the codecs tear themselves out as they pass (the connection). The client then volunteers two packets straight away: its own BrandPayload and ServerboundClientInformationPacket, which is where the server learns the language it will pick a code of conduct in.

What disconnects a login: a version mismatch, a ban, a full whitelist-only server, a failed session check on a non-singleplayer host, an unexpected custom-query answer (ServerLoginPacketListenerImpl.State.NEGOTIATING is declared and never assigned — ClientboundCustomQueryPacket decodes every payload as DiscardedQueryPayload, and an answer just disconnects), or six hundred ticks.

Configuration

flowchart LR
    S["startConfiguration: BrandPayload, server links, enabled features, outside the queue"] --> R["SynchronizeRegistriesTask"]
    R --> C["ServerCodeOfConductConfigurationTask, if the server has one"]
    C --> P["ServerResourcePackConfigurationTask, if the server has one"]
    P --> W["returnToWorld appends the last two"]
    W --> PS["PrepareSpawnTask: Preparing, then Ready"]
    PS --> J["JoinWorldTask sends ClientboundFinishConfigurationPacket, terminal"]
    J --> F["handleConfigurationFinished: outbound play, the gate again, then spawnPlayer"]

SynchronizeRegistriesTask is the reason configuration exists, and the queue around it is strictly serial. ServerConfigurationPacketListenerImpl.startConfiguration sends three things outside the queue — the server’s BrandPayload, ClientboundServerLinksPacket if there are links, and ClientboundUpdateEnabledFeaturesPacket — then queues the registry task, a code-of-conduct task if the server has one and a resource-pack task if it has one, before ServerConfigurationPacketListenerImpl.returnToWorld appends PrepareSpawnTask and JoinWorldTask and starts the first. Each ConfigurationTask finishes before the next begins: ServerConfigurationPacketListenerImpl.finishCurrentTask rejects a completion naming the wrong task type, and an exception out of any task disconnects the client.

Registry and tag sync. The task begins with ClientboundSelectKnownPacks, a list of KnownPack records naming, by namespace, id and version, those of the server’s packs that declare one — PackLocationInfo.knownPackInfo is an optional, so a world’s own datapack is simply absent from the request; the client matches them against its bundled vanilla repository through KnownPacksManager.trySelectingPacks and replies with the subset it recognises. If that reply is not exactly the requested list — same packs, same order — the server discards the negotiation and re-sends everything; it is all or nothing, never a per-pack intersection. Then one ClientboundRegistryDataPacket per registry, walking RegistryDataLoader.SYNCHRONIZED_REGISTRIES, each element a RegistrySynchronization.PackedRegistryEntry whose data is omitted when the element came from a pack the client already has — the entire point of the negotiation — written as NBT with the registry’s own element codec. Then one ClientboundUpdateTagsPacket covering the networkable registries and the static ones too — the surprise, since the data packets are dynamic-only — with empty payloads dropped (tags). On the client, RegistryDataCollector accumulates the contents and the tags and only resolves them at ClientConfigurationPacketListenerImpl.handleConfigurationFinished, loading them on a background executor against the negotiated packs — and blocking on the result, so the load is dispatched away rather than genuinely asynchronous — before constructing the ClientPacketListener with the finished RegistryAccess. In singleplayer the result is narrowed to the server’s own objects, so both sides share instances.

The seam is not where it looks. Four of the server’s configuration handlers hop to the main thread — ServerConfigurationPacketListenerImpl.handleSelectKnownPacks, ServerConfigurationPacketListenerImpl.handleConfigurationFinished and, from the common base, the resource-pack response and ServerCommonPacketListenerImpl.handleCustomClickAction — while the client-information and code-of-conduct handlers, like the keep-alive and ping handlers they inherit, stay on the Netty thread. That last one is load-bearing: accepting a code of conduct finishes a task, which starts the next task on the Netty thread, and the next task may be PrepareSpawnTask, whose first act is to read a player save file and resolve a spawn position. ServerConfigurationPacketListenerImpl.tick runs each tick to drive the current task and keep the spawn chunks loaded.

PrepareSpawnTask is two states, and the player is born in neither. Its PrepareSpawnTask.Preparing state reads the save file for a stored position, resolves a level, runs PlayerSpawnFinder.findSpawn, takes a TicketType.PLAYER_SPAWN ticket at PrepareSpawnTask.PREPARE_CHUNK_RADIUS and waits — which is what ConfigurationTask.tick exists for. When the chunks arrive it becomes PrepareSpawnTask.Ready, reports itself finished and does nothing further except re-arm the ticket every tick through PrepareSpawnTask.keepAlive. JoinWorldTask then sends the terminal packet. Only when the client’s ServerboundFinishConfigurationPacket arrives does ServerConfigurationPacketListenerImpl.handleConfigurationFinished swap the outbound protocol to play, re-run the duplicate-player check and PlayerList.canPlayerLogin — because a ban or a full server can arrive in the seconds a configuration takes — and call PrepareSpawnTask.spawnPlayer, which constructs the ServerPlayer, reads the save data into it — a second read, the first having happened when the task started — and hands it to PlayerList.placeNewPlayer, which installs the inbound play protocol. Players and sessions owns the rest of that story. Everything between the join task and that handler is a server holding a ticket on chunks for a player that does not exist.

What disconnects a configuration: a task that throws on start or on tick, a completion for the wrong task, a ban or a full server at the second check, and an exception while placing the player.

Play, and the way back

The play protocol is installed from two different places on each side: the server swaps outbound a line into the finish handler and inbound inside PlayerList.placeNewPlayer; the client swaps inbound, sends the finish packet, then swaps outbound. That is why the server can be encoding play packets while still nominally in the configuration listener. Chat session keys are not part of any of this: they are negotiated in play, after the client learns the server’s mode from ClientboundLoginPacket (chat and signing).

A reconfigure does not re-run configuration. ServerGamePacketListenerImpl.switchToConfig removes the player from the world, sends ClientboundStartConfigurationPacket and swaps outbound; the client’s ClientPacketListener.handleConfigurationStart flushes its chat queue, stashes the chat state and carries the registry access, feature flags, brand and server links forward in a rebuilt cookie, then answers ServerboundConfigurationAcknowledgedPacket; and ServerGamePacketListenerImpl.handleConfigurationAcknowledged installs a configuration listener without calling ServerConfigurationPacketListenerImpl.startConfiguration. No registries, tags, feature flags or brand are re-sent, and none need to be. The player parks in configuration with an empty queue until ServerConfigurationPacketListenerImpl.returnToWorld re-queues the spawn and join tasks. Both directions are reachable in vanilla only from DebugConfigCommand.

What the phases leave unused

Seven packets are terminal, and four of them are the two handshakes that bracket configuration; ClientIntentionPacket is one of the seven, so the very first packet of a connection already tears out the codec that decoded it.

The creative-inventory packet is filtered at the codec, asymmetrically. GameProtocols.HAS_INFINITE_MATERIALS refuses to encode or decode the packet when its context says the connection is not in creative — but the client’s own context answers GameProtocols.Context.hasInfiniteMaterials true unconditionally, while the server’s is ServerGamePacketListenerImpl answering from the real player, so the symmetric modifier bites on exactly one side (packets and stream codecs). Compression is asymmetric the same way: the server validates that a compressed frame really was above the threshold; the client does not.

Cookies, transfers and the chat reset are proxy hooks. A ServerboundCookieResponsePacket arriving at any server listener is an unexpected query and a disconnect, and nothing in the tree constructs ClientboundStoreCookiePacket or ClientboundCookieRequestPacket. Of the transfer machinery only ClientboundTransferPacket has a vanilla caller, /transfer: it sends the client to another server, which sees a ClientIntent.TRANSFER handshake and a transferred flag in its cookie, and ClientCommonPacketListenerImpl.shouldHandleMessage keeps accepting store-cookie and transfer packets while a transfer is in flight, which is what lets a proxy’s trailing state land. ClientboundResetChatPacket is registered and handled and never sent. The rest is fully implemented on the client and unused by the server.

For a 1.21-era reader. The assumption that “packet handlers run on the game thread” is exactly backwards for the first two phases: the handshake and login listeners run to completion on the Netty thread, and the first PacketUtils.ensureRunningOnSameThread in a connection’s life is in configuration. The connection is encrypted before it is authenticated — the server installs both ciphers while handling the key packet, before its own session-service call has begun.

Where to look

ConnectionProtocol · ProtocolInfo · ProtocolInfoBuilder · ServerHandshakePacketListenerImpl · ServerStatusPacketListenerImpl · ServerLoginPacketListenerImpl · ServerConfigurationPacketListenerImpl · ClientHandshakePacketListenerImpl · ClientConfigurationPacketListenerImpl · ConfigurationTask · SynchronizeRegistriesTask · PrepareSpawnTask · JoinWorldTask · RegistrySynchronization · KnownPack · Crypt · ServerCommonPacketListenerImpl · CommonListenerCookie


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.