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
| class | role | thread |
|---|---|---|
ConnectionProtocol | the five phases — a bare enum of labels; the codec lives in a ProtocolInfo, the behaviour in a PacketListener | — |
Connection | one channel, and Connection.setupInboundProtocol / Connection.setupOutboundProtocol at every transition | Netty |
ServerHandshakePacketListenerImpl | the three-way switch and the version gate | Netty |
ServerLoginPacketListenerImpl | the login state machine; its handlers set a volatile state and its ServerLoginPacketListenerImpl.tick acts on it | Netty and the authenticator thread, ticked from Server |
ServerConfigurationPacketListenerImpl | the serial task queue, and the handler that finally builds the player | mixed — see below |
ClientHandshakePacketListenerImpl · ClientConfigurationPacketListenerImpl | the client’s two halves: the session-service call, then the registries and tags it accumulates before constructing ClientPacketListener | Netty, the IO pool for the session call, and Render for most of the second |
ServerCommonPacketListenerImpl · CommonListenerCookie | what configuration and play share — the packets legal in both, and the state that survives a switch between them | Netty and Server |
PrepareSpawnTask | finds a spawn, tickets its chunks, waits — and later, on request, spawns the player | Server, 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.
| phase | serverbound listener | clientbound listener | ProtocolInfo |
|---|---|---|---|
ConnectionProtocol.HANDSHAKING | ServerHandshakePacketListenerImpl, or MemoryServerHandshakePacketListenerImpl in singleplayer | none — there is no clientbound handshake protocol | HandshakeProtocols.SERVERBOUND, one packet |
ConnectionProtocol.STATUS | ServerStatusPacketListenerImpl | an anonymous ClientStatusPacketListener inside ServerStatusPinger | StatusProtocols.SERVERBOUND binds a raw buffer; StatusProtocols.CLIENTBOUND a FriendlyByteBuf |
ConnectionProtocol.LOGIN | ServerLoginPacketListenerImpl | ClientHandshakePacketListenerImpl | LoginProtocols.SERVERBOUND / LoginProtocols.CLIENTBOUND |
ConnectionProtocol.CONFIGURATION | ServerConfigurationPacketListenerImpl | ClientConfigurationPacketListenerImpl | ConfigurationProtocols.SERVERBOUND / ConfigurationProtocols.CLIENTBOUND |
ConnectionProtocol.PLAY | ServerGamePacketListenerImpl | ClientPacketListener | not 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.ensureRunningOnSameThreadin 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.