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.3 · 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 few seconds it speaks four different languages in turn: handshaking, login, configuration, play. There is a fifth, status, and a login never touches it — it is the phase the server list uses to ask a server how many players it has, and it is a dead end by design. Each of the five is a ConnectionProtocol, each has its own packet set, and each that hands over to another does so by a packet marked terminal — a flag on the packet class that makes the codec which decoded it tear itself out of the pipeline as it passes (the connection). All but handshaking have a listener at both ends; handshaking is serverbound only, so the client has nothing to listen with.

The surprise is not in any of that. It is that the ServerPlayer — the object, its save data, its position, the chunks under it — is prepared during configuration, by PrepareSpawnTask, and constructed only after the client has acknowledged that configuration is over. For the seconds in between, a server is holding chunk tickets for a player that does not exist, and by the time the object is built, the server’s side of the connection already encodes play.

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 and the authenticator thread, ticked from Server
ServerConfigurationPacketListenerImplthe serial task queue, and the handler that finally builds the playermixed — see below
ClientHandshakePacketListenerImpl · ClientConfigurationPacketListenerImplthe client’s two halves: the session-service call, then the registries and tags it accumulates before constructing ClientPacketListenerNetty, the IO pool for the session call, and Render for most of the second
ServerCommonPacketListenerImpl · CommonListenerCookiewhat configuration and play share — the packets legal in both, and the state that survives a switch between themNetty and Server
PrepareSpawnTaskfinds a spawn, tickets its chunks, waits — and later, on request, spawns the playerServer, but Netty for its start straight after a code of conduct

The five phases

stateDiagram-v2
    [*] --> 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 packet named here is terminal, so the codec that decoded it is already gone

The five phases, each arrow between two of them carrying the packet — or the pair of them — that changes one. The arrow back out of play is the only one that goes up, which is what makes configuration the only phase entered from two others.

Seven packets in the game carry the terminal flag, and every one of them labels an arrow above. ClientIntentionPacket labels two, which is the striking one: the very first packet a connection ever sends already tears out the codec that decoded it. The remaining six are three pairs, and every pair enters or leaves configuration, the only phase entered from two others.

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.STATUSServerStatusPacketListenerImplan anonymous ClientStatusPacketListener inside ServerStatusPingerStatusProtocols.SERVERBOUND binds a raw buffer; StatusProtocols.CLIENTBOUND a FriendlyByteBuf
ConnectionProtocol.LOGINServerLoginPacketListenerImplClientHandshakePacketListenerImplLoginProtocols.SERVERBOUND / LoginProtocols.CLIENTBOUND
ConnectionProtocol.CONFIGURATIONServerConfigurationPacketListenerImplClientConfigurationPacketListenerImplConfigurationProtocols.SERVERBOUND / ConfigurationProtocols.CLIENTBOUND
ConnectionProtocol.PLAYServerGamePacketListenerImplClientPacketListenernot pre-bound — GameProtocols.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 one thing about the column that is a fact about the phases is that play is the only row not pre-bound: its codecs write registry ids, so its two templates are bound per connection at the configuration-to-play switch, which is the only transition in a connection’s life that ties the codecs to a set of registries as well as changing which packets are legal.

Two listeners sit under the phases. ServerCommonPacketListenerImpl is the shared base of the server’s configuration and play listeners and holds nearly everything legal in both (each subclass handles the client information itself) — 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, and the two refusals are not symmetric: a version below protocol 754 is told outdated_client, and anything else that does not match is told incompatible. All three of the handshake’s refusals on the login path — these two and the transfer refusal — go out as ClientboundLoginDisconnectPacket under the login clientbound protocol, so the client can render a reason: ServerHandshakePacketListenerImpl.beginLogin installs it for every login before its check, and the transfer refusal installs it purely to send one. 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

The login phase is a state machine on one field, and the states are the ones the server moves through between accepting a socket and having a profile it believes.

stateDiagram-v2
    [*] --> HELLO
    HELLO --> KEY : online mode, ClientboundHelloPacket
    HELLO --> VERIFYING : the singleplayer profile
    HELLO --> VERIFYING : offline mode
    KEY --> AUTHENTICATING : ServerboundKeyPacket
    AUTHENTICATING --> VERIFYING : the authenticator thread returns
    VERIFYING --> WAITING_FOR_DUPE_DISCONNECT : tick, a duplicate is online
    VERIFYING --> PROTOCOL_SWITCHING : tick, ClientboundLoginFinishedPacket
    WAITING_FOR_DUPE_DISCONNECT --> PROTOCOL_SWITCHING : tick, the old one is gone
    PROTOCOL_SWITCHING --> ACCEPTED : ServerboundLoginAcknowledgedPacket
    note right of VERIFYING : the three tick edges are the server thread's, one the authenticator thread's, the rest Netty's

Seven of the eight ServerLoginPacketListenerImpl.State constants, and the three edges the server thread owns. Look at where the forks are: the machine branches once out of the hello, on how — or whether — this login is authenticated, and then again on whether the profile is already playing.

No handler on ServerLoginPacketListenerImpl ever schedules itself onto another thread (there is not one PacketUtils.ensureRunningOnSameThread call in the class), and yet the login is finished on the server thread, which is why its ServerLoginPacketListenerImpl.state field is volatile: the packet handlers run on the Netty thread and set the state, as the authenticator thread below does, and ServerLoginPacketListenerImpl.tick — reached from MinecraftServer.tickConnection through ServerConnectionListener.tick and Connection.tick, the tick a game thread gives a connection (the connection) — 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 — a transition out of every state above but the last, and the one the diagram cannot draw.

The diagram names three states the paragraphs below do not: ServerLoginPacketListenerImpl.State.AUTHENTICATING is the wait on the session service, ServerLoginPacketListenerImpl.State.PROTOCOL_SWITCHING is the gap between the server sending ClientboundLoginFinishedPacket and the client acknowledging it, and ServerLoginPacketListenerImpl.State.ACCEPTED is where the machine stops, because configuration has its own listener.

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
    box transparent the client
    participant CHPL as ClientHandshake<br/>PacketListenerImpl
    end
    box transparent the server
    participant SLPL as ServerLoginPacket<br/>ListenerImpl
    end
    participant Sess as the session service

    SLPL->>CHPL: ClientboundHelloPacket, an RSA public key and a challenge
    CHPL->>CHPL: a new AES secret, and a digest over id, secret and key
    CHPL->>Sess: joinServer, on the client's IO pool, before the key packet
    CHPL->>SLPL: ServerboundKeyPacket, RSA-encrypted, ciphers attached to the send
    SLPL->>SLPL: the challenge checked, the secret recovered, then<br/>Connection.setEncryptionKey
    SLPL->>Sess: hasJoinedServer, on a fresh User Authenticator thread
    Sess-->>SLPL: the authenticated profile, state VERIFYING
    Note over SLPL: the next server tick runs bans, whitelist, compression, duplicates

Both ends authenticating against the same external service, the client first. Read it downwards: the server has installed both ciphers by the fifth line, two lines before it has any idea who this is.

Read the diagram for the order, because the order is the point: the server installs both ciphers while handling the key packet, before its own session-service call has begun. An unauthenticated connection is already encrypted. The client’s asymmetry is the mirror of it — it calls the session service before ServerboundKeyPacket goes anywhere and sends it from that callback, attaching its own ciphers to the send, so the key packet itself travels in the clear and nothing after it does.

Authentication is a plain thread with two fallbacks. It calls the session service and, on success, reports login activity, 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 player has left the player list.

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 carries the client’s language, view distance and skin-part settings — and is therefore the packet a server with a code of conduct reads before choosing which translation of it to send.

What disconnects a login: a version mismatch, a refused transfer, a ban, a whitelist the player is not on, a full server, a failed session check on a non-singleplayer host, any ServerboundCustomQueryAnswerPacket or cookie answer, a malformed or out-of-order packet, or six hundred ticks. Either answer disconnects because vanilla never asks: ServerLoginPacketListenerImpl.State.NEGOTIATING is declared and never assigned, ClientboundCustomQueryPacket decodes every payload as DiscardedQueryPayload, and nothing sends a cookie request.

Configuration

Configuration is a queue. The order below is fixed, each task finishes before the next begins, and the last two exist only to get a player into a world.

flowchart TD
    S["up to three packets, sent before any task runs"]:::server
    S --> Q
    subgraph Q["the serial queue"]
      direction TB
      R["SynchronizeRegistriesTask"]:::server --> C["ServerCodeOfConduct<br/>ConfigurationTask"]:::server
      C --> P["ServerResourcePack<br/>ConfigurationTask"]:::server
      P --> PS["PrepareSpawnTask: a spawn, and a ticket"]:::server
      PS --> J["JoinWorldTask: the terminal packet"]:::server
    end
    Q -- "ClientboundFinishConfigurationPacket" --> CL["the client rebuilds its registries"]:::client
    CL -- "ServerboundFinishConfigurationPacket" --> F["the play protocol, the gate again, the player"]:::server

The phase as a queue of five tasks, strictly one at a time — the middle two only if the server has a code of conduct or a resource pack — with the packets that precede the queue and the round trip that ends it. The two arrows at the foot are the phase’s oddity: between them the server is holding a ticket on chunks for a player that does not exist yet.

SynchronizeRegistriesTask is the reason configuration exists, and the queue around it is strictly serial. ServerConfigurationPacketListenerImpl.startConfiguration sends up to 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 (ServerCodeOfConductConfigurationTask, whose two packets are ClientboundCodeOfConductPacket and the client’s ServerboundAcceptCodeOfConductPacket) 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 is why this phase exists, and what crosses belongs to identifiers and registries — the known-pack negotiation, the all-or-nothing comparison, the empty payloads for elements the client can re-read from its own jar, and the two layers the client rebuilds. What belongs to the phase is the order and the count: one ClientboundSelectKnownPacks and its reply, then one ClientboundRegistryDataPacket per registry, then a single ClientboundUpdateTagsPacket that covers the networkable registries and the static ones too — the surprise, since the data packets are dynamic-only, and the reason a tag has to land before any play traffic does (tags). None of it is applied as it arrives: RegistryDataCollector accumulates the whole exchange and only resolves it at ClientConfigurationPacketListenerImpl.handleConfigurationFinished, the handler that goes on to construct the ClientPacketListener.

The seam is not where it looks. Four of the server’s configuration handlers hop to the Server 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 only the second builds the player, and only when asked. Its PrepareSpawnTask.Preparing state waits for the spawn position the task’s start resolved (a search, for a player with none saved), tickets the chunks and then reports not finished from ConfigurationTask.tick until they arrive; PrepareSpawnTask.Ready reports finished and does nothing but re-arm the ticket until it is asked to spawn. How it finds the spawn, why the ticket has to be re-armed every tick, and what the two whole-file reads cost belong to players and sessions. JoinWorldTask then sends the terminal packet, and only when the client’s ServerboundFinishConfigurationPacket arrives does ServerConfigurationPacketListenerImpl.handleConfigurationFinished swap the outbound protocol to play, refuse a duplicate of the player, run the admission gate a second time (admission is a Component or nothing — a ban or a full server can arrive in the seconds a configuration takes) and call PrepareSpawnTask.spawnPlayer, which is where the ServerPlayer is finally constructed and handed to PlayerList.placeNewPlayer. Everything between the join task and that handler is a server holding a ticket on chunks for a player that does not exist — which is the phase’s own oddity, and the reason this page opens on it.

What disconnects a configuration: a task that throws on start or on tick, a completion for the wrong task, a declined resource pack the server requires, a cookie answer, a duplicate at the finish or a refusal at the admission gate’s second run (a ban, the whitelist, a full server), and an exception while placing the player. Notably not a deadline on the queue. Login has one — six hundred ticks — and configuration’s queue has none: ServerConfigurationPacketListenerImpl.tick counts nothing, calling the common base’s keep-alive and then the current task, so what kills a stuck task is a client that stops answering keep-alives rather than one that takes too long. Only the round trip that ends the phase has a clock, for anyone but the singleplayer host: the terminal packet closes the listener, which stops sending keep-alives and allows fifteen seconds for the answer (the connection). A slow configuration can be the server still finding chunks for the player, and the queue lets it be.

Play, and the way back

The play protocol is installed in two halves on each side: the server swaps outbound early in the finish handler and inbound inside PlayerList.placeNewPlayer; the client, in one handler, swaps inbound, sends the finish packet, then swaps outbound. That is why the server’s side already encodes play while still nominally in the configuration listener, and a refusal at the finish goes out as a play packet. 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, which the game registers only in a dedicated server’s command set, and only when started with its debug system properties.

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’ handlers run to completion on the Netty thread, and the first PacketUtils.ensureRunningOnSameThread in a connection’s life is in configuration.

Where to look

Read the four listeners, the server’s three in the order a login meets them, and the whole page is in them. ServerHandshakePacketListenerImpl is little more than one switch. ServerLoginPacketListenerImpl is the one to spend time on: read ServerLoginPacketListenerImpl.tick first, then the handlers below it, and the volatile state field above both will explain itself. ServerConfigurationPacketListenerImpl is the task queue, and ServerConfigurationPacketListenerImpl.handleConfigurationFinished is where the player is finally built. ClientHandshakePacketListenerImpl is the other side of the encryption exchange and worth reading against the server’s.

Then the tasks themselves — ConfigurationTask is a three-method interface, and PrepareSpawnTask and JoinWorldTask are the two that matter here; SynchronizeRegistriesTask is the reason the phase exists and is Part II’s subject as much as this page’s.

Two doors this page only points at: ConnectionProtocol, which takes ten seconds to read and is worth it for how little is in it, and Crypt, the key, digest, cipher and salt helpers the login and chat signing share, in one utility class.


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