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

Packets and stream codecs

Verified against Minecraft 26.2 · Part IX · Someone says hello in chat, and you stop the message on its way out of the server to ask what it actually is.

The connection hands you a frame: a length, then a run of bytes that a handler on a Netty thread is about to turn into a method call on the other side. This page is what is inside that frame. A chat line leaving the server is a ClientboundSystemChatPacket — a record of a Component and a boolean — and it has no write method, no byte layout of its own, and no number. The number that goes on the wire in front of it is written down nowhere in the game: not on the packet, not on its PacketType, not in any table a human maintains. A packet’s id is the position of one line in a chain of registration calls. Swap two of those lines and the whole protocol renumbers — and because a handful of packet types are registered into several phases, the same packet type is a different number in each phase it appears in.

For a 1.21-era reader. Packet no longer knows how to write itself. There is no write method on the interface and no buffer constructor it is obliged to have. Serialisation is a STREAM_CODEC static field that the protocol description reads — which is why one packet class can have two of them, and why a packet class need not own one at all.

The cast

classwhat it decidesthread
Packetthat a message is a value with a type, one handler method and two flags — and nothing at all about bytesany
PacketTypewhich message this is: a direction and a name, and no number
StreamCodecone value’s bytes — an encoder and a decoder over a ByteBuf, composed out of its fields’ codecsNetty
ByteBufCodecsthe primitive vocabulary every packet codec is built from, and where the read limits sitNetty
IdDispatchCodecone codec for a whole phase: a var-int id, then delegate to the entry that id namesNetty
ProtocolInfowhat a configured connection holds — the phase, the direction, that one codec, and the bundlerNetty
ProtocolInfoBuilderthe registration chain, and therefore every packet number in the gameclass-load, or the configuration-to-play swap
RegistryFriendlyByteBufthat a registry id on the wire means something, by carrying the RegistryAccess it is relative toNetty

The catalogue of which packets exist is generated, not written: reference/packets.md lists all 232 packet types across the eight *PacketTypes classes that declare them.

A packet is a value, a name and a direction

Packet is an interface with four methods and one static helper.

memberwhat it says
Packet.typethe PacketType, always a constant from one of the eight *PacketTypes classes
Packet.handlehands this packet to one named method on the phase’s listener interface
Packet.isSkippabledefault false. True for the chat-shaped packets, so a failure to encode one is dropped rather than fatal
Packet.isTerminaldefault false. True for the seven packets that end a protocol phase
Packet.codeca static convenience: StreamCodec.ofMember under a friendlier name

Exactly one packet refuses to be handled at all: BundleDelimiterPacket makes Packet.handle final and throws, because a delimiter is consumed by the pipeline and must never reach a listener. The skippable set is the five chat-shaped packets — ClientboundSystemChatPacket, ClientboundPlayerChatPacket, ClientboundDisguisedChatPacket, ClientboundPlayerCombatKillPacket and ClientboundTagQueryPacket. The terminal set is exactly the seven transition packets drawn on protocol phases and no others; what the flag does to the pipeline is the connection’s business. Beware the namesake: ServerboundResourcePackPacket.Action.isTerminal asks whether a resource-pack response is a final answer, and that packet is not terminal.

PacketType is a record of two things — a PacketFlow and an Identifier. A direction and a name; no number, no version, no size. PacketFlow is the two-constant enum PacketFlow.SERVERBOUND / PacketFlow.CLIENTBOUND, with PacketFlow.getOpposite and PacketFlow.id.

Three shapes of packet class coexist in the tree, and two of them carry the argument. The modern one is a record whose STREAM_CODEC is a StreamCodec.composite naming each component’s codec and accessor — ClientboundSystemChatPacket is one. The older one is a plain class with a private buffer constructor and a private write method, joined into a codec by Packet.codec; ServerboundSwingPacket, ClientboundKeepAlivePacket and ClientboundSetHealthPacket are these, and StreamMemberEncoder exists chiefly so that second form can bind a member reference as its encoder half — CustomPacketPayload is its one other client. The third shape is the one with no fields to serialise at all: a singleton whose codec is StreamCodec.unit, fourteen of them, of which ServerboundFinishConfigurationPacket is the one this part’s login trace turns on.

From two fields to a numbered blob

flowchart TB
    subgraph V["the value"]
      F1["content, a Component"]
      F2["overlay, a boolean"]
    end
    subgraph C["one stream codec per component"]
      SC1["ComponentSerialization.TRUSTED_STREAM_CODEC"]
      SC2["ByteBufCodecs.BOOL"]
    end
    F1 --> SC1
    F2 --> SC2
    SC1 --> COMP["ClientboundSystemChatPacket.STREAM_CODEC, a StreamCodec.composite of two codec-and-getter pairs plus the constructor"]
    SC2 --> COMP
    COMP --> ENTRY["one addPacket call in GameProtocols.CLIENTBOUND_TEMPLATE, pairing that codec with GamePacketTypes.CLIENTBOUND_SYSTEM_CHAT"]
    ENTRY --> WRAP["mapStream, applied when the protocol is bound, wraps every call in a fresh RegistryFriendlyByteBuf"]
    WRAP --> DISP["IdDispatchCodec for the whole clientbound play phase. The id is this entry's index in the chain"]
    DISP --> OUT["a VarInt id, then the two fields in argument order"]

Read it downwards and you have the page. Nothing above the ProtocolInfoBuilder.addPacket line knows anything about ids, and nothing below it knows anything about chat.

One codec serves an entire phase. ProtocolInfo.codec is a single StreamCodec — an IdDispatchCodec — and not a table the encoder walks: encoding looks the PacketType up in a map, and a type never registered in this protocol is an encoder error naming the unknown packet, which is how a configuration-phase packet sent during play fails. Decoding reads the id, bounds-checks it and delegates — and then must have consumed the frame exactly, or PacketDecoder raises an error naming how many bytes were left over. An under-read corrupts nothing visible until much later, so it is caught at the one point where the answer is still knowable. All of that runs on the Netty event loop, inside PacketEncoder and PacketDecoder, never on a game thread; the framing, the compression, the ciphers and the later hop to the game thread are the connection’s.

The codec layer is small, and composition is all of it

net/minecraft/network/codec is a handful of files and does all of the composing; the byte-level encodings themselves live outside it, in VarInt, VarLong, Utf8String, LpVec3 and FriendlyByteBuf. StreamCodec is one interface extending StreamEncoder and StreamDecoder, so StreamEncoder.encode takes a buffer and a value and StreamDecoder.decode takes a buffer and returns one. It is deliberately not a Codec: a packet is written once, read once and must be small, so it gets hand-laid bytes rather than a document in some format — the distinction is codecs, NBT and JSON’s.

The constructors and combinators are StreamCodec.of, StreamCodec.ofMember, StreamCodec.unit, StreamCodec.map, StreamCodec.mapStream, StreamCodec.apply, StreamCodec.dispatch, StreamCodec.recursive and StreamCodec.cast — plus StreamCodec.composite in twelve arities, one through twelve pairs of codec and getter followed by a constructor. Fields encode and decode strictly in argument order, and that ordering is the format specification: there is no other statement anywhere of what a packet’s bytes look like. StreamCodec.CodecOperation lets StreamCodec.apply read left to right, and StreamCodec.dispatch is how registry-dispatched values travel — ConsumeEffect.STREAM_CODEC, SlotDisplay.STREAM_CODEC and RecipeDisplay.STREAM_CODEC are built with it.

ByteBufCodecs is the primitive library underneath: SCREAMING_CASE constants for the fixed things, lowerCamel factories for the parameterised ones.

whatthe names
numbersByteBufCodecs.BOOL, ByteBufCodecs.BYTE, ByteBufCodecs.SHORT, ByteBufCodecs.UNSIGNED_SHORT, ByteBufCodecs.INT, ByteBufCodecs.VAR_INT, ByteBufCodecs.LONG, ByteBufCodecs.VAR_LONG, ByteBufCodecs.FLOAT, ByteBufCodecs.DOUBLE
bytes and textByteBufCodecs.BYTE_ARRAY, ByteBufCodecs.LONG_ARRAY, ByteBufCodecs.STRING_UTF8, ByteBufCodecs.byteArray, ByteBufCodecs.stringUtf8, ByteBufCodecs.PLAYER_NAME
tags and JSONByteBufCodecs.TAG, ByteBufCodecs.COMPOUND_TAG, ByteBufCodecs.TRUSTED_TAG, ByteBufCodecs.TRUSTED_COMPOUND_TAG, ByteBufCodecs.lenientJson
shapes and identitiesByteBufCodecs.VECTOR3F, ByteBufCodecs.QUATERNIONF, ByteBufCodecs.RGB_COLOR, ByteBufCodecs.CONTAINER_ID, ByteBufCodecs.GAME_PROFILE, ByteBufCodecs.GAME_PROFILE_PROPERTIES
structureByteBufCodecs.optional, ByteBufCodecs.collection, ByteBufCodecs.list, ByteBufCodecs.map, ByteBufCodecs.either, ByteBufCodecs.lengthPrefixed
registriesByteBufCodecs.idMapper, ByteBufCodecs.registry, ByteBufCodecs.holder, ByteBufCodecs.holderRegistry, ByteBufCodecs.holderSet

Two are worth naming for what they encode rather than for what they are. ByteBufCodecs.ROTATION_BYTE is one byte meaning 1/256 of a full turn — a little over a degree, with Mth.packDegrees and Mth.unpackDegrees doing the arithmetic — and ByteBufCodecs.OPTIONAL_VAR_INT spends zero for absent and value-plus-one otherwise.

The bridge to the disk-and-JSON codecs of Part II is ByteBufCodecs.fromCodec and its relatives, which run an ordinary Codec into a carrier format and put the result on the wire. The combinator underneath takes the ops as an argument and is format-agnostic, and its six NBT entry points all pass NbtOps, so a Codec on the wire almost always means a compound tag. Almost: the combinator is public, and two packets call it with JsonOps and carry JSON strings instead — the server-list response, ClientboundStatusResponsePacket, and the login kick, ClientboundLoginDisconnectPacket. They are the two a player is likeliest to have seen. Beneath it, ByteBufCodecs.tagCodec takes an NbtAccounter supplier — which is exactly what trusted turns out to mean, below.

IdDispatchCodec is the class that makes a protocol out of a pile of codecs: a list of serialisers, a type-to-int map, a var-int written in front and a delegation behind it. IdDispatchCodec.DontDecorateException is the marker meaning rethrow me as I am, do not wrap me, and IdDispatchCodec.Builder.build refuses a duplicate PacketType outright, so registering one type twice in a protocol fails loudly rather than silently shadowing an id. Loudly, but not always early: the check runs when the template is bound, which for the four static phases is class-load and for play is the first connection’s configuration-to-play switch.

Which buffer, and why play needs its own

flowchart LR
    RAW["ByteBuf, what the pipeline hands the codec"] --> ID["status serverbound binds the identity function and never wraps at all"]
    RAW --> FBB["FriendlyByteBuf for handshaking, status clientbound, login and configuration"]
    FBB --> RFBB["RegistryFriendlyByteBuf for play, adding one field, a RegistryAccess"]

FriendlyByteBuf is a ByteBuf decorator declaring a hundred and fifty-two readers and writers, a hundred and twenty-one of which add a wire format the plain buffer knows nothing about — FriendlyByteBuf.readVarInt, FriendlyByteBuf.writeUtf, FriendlyByteBuf.readIdentifier, FriendlyByteBuf.writeResourceKey, FriendlyByteBuf.readNbt, FriendlyByteBuf.readCollection, FriendlyByteBuf.readEnumSet, FriendlyByteBuf.readBlockPos, FriendlyByteBuf.readBlockHitResult, FriendlyByteBuf.readWithCodec and so on. It holds the two length constants FriendlyByteBuf.MAX_STRING_LENGTH and FriendlyByteBuf.MAX_COMPONENT_STRING_LENGTH, and also FriendlyByteBuf.limitValue, the wrapper an old-style packet puts round a collection constructor to get the cap ByteBufCodecs.collection gives for free.

RegistryFriendlyByteBuf extends it and adds exactly one field, a RegistryAccess, behind RegistryFriendlyByteBuf.registryAccess. It exists because a numeric registry id means nothing on its own — item number 37 is only an item relative to the registry set the server sent during configuration (identifiers and registries). Every codec that writes a registry id needs one: ByteBufCodecs.registry, ByteBufCodecs.holderRegistry, ByteBufCodecs.holder, ByteBufCodecs.holderSet, ByteBufCodecs.fromCodecWithRegistries and ByteBufCodecs.registryFriendlyLengthPrefixed — and therefore ItemStack.STREAM_CODEC, DataComponentPatch.STREAM_CODEC (data components), ComponentSerialization.STREAM_CODEC and HashedStack.STREAM_CODEC. The wrapper is a throwaway, not a pipeline object: ProtocolInfoBuilder applies the decorator — RegistryFriendlyByteBuf.decorator for play — through StreamCodec.mapStream, so a fresh view is constructed round the raw buffer on every single encode and every single decode.

Below all of it the actual encodings live in VarInt, VarLong, Utf8String and LpVec3, the quantised position behind Vec3.LP_STREAM_CODEC. Two everyday values are worth naming because they are not special-cased: Identifier.STREAM_CODEC is a plain UTF-8 string under the ordinary 32,767-character cap — an identifier on the wire is text, never an interned number — and UUIDUtil.STREAM_CODEC is two longs.

Where a packet’s number comes from

ProtocolInfo is what a configured connection actually holds: ProtocolInfo.id (a ConnectionProtocol), ProtocolInfo.flow, ProtocolInfo.codec — the single phase-wide StreamCodec — and a nullable ProtocolInfo.bundlerInfo. It is built by ProtocolInfoBuilder, whose ProtocolInfoBuilder.addPacket and ProtocolInfoBuilder.withBundlePacket are the registration calls and whose ProtocolInfoBuilder.buildUnbound yields an UnboundProtocol or a SimpleUnboundProtocol — a protocol that knows everything except which buffer type to wrap the bytes in. UnboundProtocol.bind supplies that. Underneath it, ProtocolCodecBuilder is the layer that talks to IdDispatchCodec.

The id is a registration index. ProtocolCodecBuilder.add appends to a list and IdDispatchCodec.Builder.build walks that list assigning 0, 1, 2 in call order, so a packet’s wire number is literally its position in the ProtocolInfoBuilder.addPacket chain in GameProtocols, ConfigurationProtocols, LoginProtocols, StatusProtocols or HandshakeProtocols. ProtocolCodecBuilder.add also asserts that the type’s PacketFlow matches the protocol’s, so a clientbound type cannot be registered into a serverbound protocol.

Those numbers are readable from outside for one reason. ProtocolInfo.DetailsProvider and ProtocolInfo.Details let tooling enumerate a phase, and ProtocolInfo.Details.listPackets hands a ProtocolInfo.Details.PacketVisitor each PacketType with its network id. The tooling is the data generator: PacketReport walks every template and writes every packet’s id in every phase into a report — the only place in the project those numbers are written down.

There are four registration entry points, one per direction and per context-or-not: ProtocolInfoBuilder.serverboundProtocol, ProtocolInfoBuilder.clientboundProtocol, ProtocolInfoBuilder.contextServerboundProtocol and ProtocolInfoBuilder.contextClientboundProtocol. The context ones let a codec ask the connection a question, and in 26.2 exactly one protocol uses one: GameProtocols.SERVERBOUND_TEMPLATE, whose context is GameProtocols.Context and whose only question is GameProtocols.Context.hasInfiniteMaterials. Every other template is a SimpleUnboundProtocol; CodecModifier is the hook a context-aware codec is installed through.

When the description is built splits the same way. Handshaking, status, login and configuration bind their buffers eagerly at class-load — and the serverbound status protocol binds the identity function, so it is the one phase and direction that never wraps its bytes at all. Play binds per connection, at the configuration-to-play transition: on the client that really is the first moment a RegistryAccess exists, while on the server the registries have been loaded since startup and the rebind happens because the protocol changed, not because the registries arrived.

A bundle is two empty markers round ordinary packets

ClientboundBundlePacket has a PacketType and no wire id at all. ProtocolInfoBuilder.withBundlePacket registers only the delimiter, ClientboundBundleDelimiterPacket, serialised with StreamCodec.unit — an id and a zero-byte body — and records a BundlerInfo beside the codec list, so a bundle on the wire is two empty markers with ordinary, individually numbered packets between them. PacketBundleUnpacker explodes an outgoing bundle into delimiter-packets-delimiter and PacketBundlePacker collects an incoming run back up; BundlePacket holds the sub-packets and BundlerInfo the logic, split between BundlerInfo.unbundlePacket outgoing and its nested BundlerInfo.Bundler incoming, with BundlerInfo.BUNDLE_SIZE_LIMIT caps a bundle at 4,096 sub-packets. Only the clientbound play protocol declares a bundle at all.

What a bundle buys is atomicity against the client’s tick. ClientPacketListener.handleBundlePacket hops to the main thread once for the whole bundle and then handles the sub-packets inline, so the client can never tick or render with half a bundle applied. There are only two senders, both in ServerEntity: ServerEntity.addPairing, which collects what ServerEntity.sendPairingData writes into a list and sends the result as one bundle, so an entity never appears mid-initialisation (what the client is told); and the motion-plus-power pair sent for a hurtling projectile.

What stops a hostile sender

The frame limit does most of the work, and it is not on this page: a frame length is at most three var-int bytes, and Varint21FrameDecoder refuses a wider prefix or a zero length before any codec sees anything (the connection). Above that sit two separate collection defences, and only the second is famous. ByteBufCodecs.readCount is the first — it compares the declared count against the codec’s own maximum and refuses outright, which is what makes the three-argument ByteBufCodecs.collection different from the two-argument one whose maximum is effectively unbounded. Behind it, ByteBufCodecs.MAX_INITIAL_COLLECTION_SIZE clamps the allocation to 65,536 entries whatever the count says, so even an accepted count cannot force a huge array up front. ByteBufCodecs.lengthPrefixed bounds the bytes instead of the count, handing the inner codec a slice it physically cannot read past.

Trusted is a real distinction in the codec library, and a statement about the read budget rather than about direction: ByteBufCodecs.fromCodecTrusted gives the NBT reader an unlimited heap quota where plain ByteBufCodecs.fromCodec gives it the default. Hence the pairs ByteBufCodecs.TAG / ByteBufCodecs.TRUSTED_TAG, ByteBufCodecs.COMPOUND_TAG / ByteBufCodecs.TRUSTED_COMPOUND_TAG and ComponentSerialization.STREAM_CODEC / ComponentSerialization.TRUSTED_STREAM_CODEC. The rule is direction: the server wrote it, so the client may trust it (codecs, NBT and JSON has the serverbound half).

Exactly one packet lets a client hand the server an arbitrary item, and it is fenced three ways. ServerboundSetCreativeModeSlotPacket uses ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC, which differs from ItemStack.OPTIONAL_STREAM_CODEC only in using DataComponentPatch.DELIMITED_STREAM_CODEC: every component’s payload is length-prefixed, and ByteBufCodecs.lengthPrefixed hands the inner codec a slice and advances the outer reader past the whole region before delegating, so a component that lies about its own length cannot mis-frame the ones after it. That is containment, not recovery — nothing catches a component that throws, and one bad component still fails the whole packet. It is then wrapped in ItemStack.validatedStreamCodec, which re-encodes the decoded stack against NullOps purely to collect its errors. And the registration carries GameProtocols.HAS_INFINITE_MATERIALS, a CodecModifier that refuses the packet whenever its context says the connection is not in creative — a SkipPacketDecoderException one way and a SkipPacketEncoderException the other, landing on one side only because the client’s context answers GameProtocols.Context.hasInfiniteMaterials true unconditionally while the server’s answers from the real player. The packet is refused in the decoder, before any handler exists to fool.

The ordinary container click is defended by carrying nothing to validate. ServerboundContainerClickPacket sends a HashedStack — either HashedStack.EMPTY or HashedStack.ActualItem, with an item holder, a count and a HashedPatchMap, itself two halves rather than one: HashedPatchMap.addedComponents, a map of component type to a hash, and HashedPatchMap.removedComponents, a bare set. A removal is as much a part of the claim as an addition, and HashedStack.matches checks both. Client-supplied component contents never cross the wire at all; see containers and menus.

The limits in one table:

limitvaluewhere
frame length prefixthree var-int bytesVarint21FrameDecoder
compressed frame2 MiBCompressionDecoder.MAXIMUM_COMPRESSED_LENGTH
decompressed frame8 MiBCompressionDecoder.MAXIMUM_UNCOMPRESSED_LENGTH
default string32,767 charsFriendlyByteBuf.MAX_STRING_LENGTH
component as string262,144FriendlyByteBuf.MAX_COMPONENT_STRING_LENGTH
player name16ByteBufCodecs.PLAYER_NAME
collection allocation65,536ByteBufCodecs.MAX_INITIAL_COLLECTION_SIZE
sub-packets in a bundle4,096BundlerInfo.BUNDLE_SIZE_LIMIT
slots in one click128ServerboundContainerClickPacket.MAX_SLOT_COUNT (named, but the codec passes the literal)
var-int / var-long5 / 10 bytesVarInt.read, VarLong.read

Custom payloads, the only extension point

The packet set is code, fixed at compile time, and no data pack adds to it. The one seam is CustomPacketPayload, with CustomPacketPayload.Type, CustomPacketPayload.createType and CustomPacketPayload.codec, carried by ClientboundCustomPayloadPacket and ServerboundCustomPayloadPacket. The route in is CustomPacketPayload.FallbackProvider, the codec handed to the dispatch as the map miss, with CustomPacketPayload.TypeAndCodec as the registration pair; BrandPayload is vanilla’s own use of the mechanism.

An unrecognised payload decodes to DiscardedPayload — a record of the identifier and nothing else. Its decoder checks the remaining length against a per-direction maximum and then skips every byte, and its encoder writes nothing. The payload is discarded, not held.

Questions players ask

Why do packet ids move — between versions, and between phases? Because nobody chose them. They are indexes into a registration chain, so inserting one line in GameProtocols shifts every packet declared after it, and the common, cookie and ping types, registered into several phases, count from zero again in each. A PacketType is a name and a direction; a number is a property of a phase, not of a type — which is why nothing in the game reads a packet number as a constant, and why PacketReport is the only place they are ever written out.

Why can one packet class have several codecs? ClientboundCustomPayloadPacket declares ClientboundCustomPayloadPacket.GAMEPLAY_STREAM_CODEC and ClientboundCustomPayloadPacket.CONFIG_STREAM_CODEC; ClientboundShowDialogPacket declares ClientboundShowDialogPacket.STREAM_CODEC and ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC. The same PacketType is registered with the first in GameProtocols and with the second in ConfigurationProtocols. Type and encoding are genuinely separate things.

Why does a malformed chat message not disconnect me? Because SkipPacketException is a marker and the skip is a decision two layers up. SkipPacketEncoderException and SkipPacketDecoderException implement it and IdDispatchCodec.DontDecorateException; PacketEncoder turns a failure on a Packet.isSkippable packet into one; PacketDecoder drains the rest of the frame and rethrows, because the frame was already delimited and nothing was ever misaligned, so the drain only satisfies Netty’s bookkeeping. What keeps the connection alive is Connection.exceptionCaught logging it and returning (the connection).

Is a modern packet safer than an old one? Measurably. FriendlyByteBuf.readCollection applies its constructor to the raw decoded count with no cap of its own, so only the frame limit bounds it, where ByteBufCodecs.collection refuses the count and then clamps the allocation. The hand-written shape is still in the tree, and FriendlyByteBuf.limitValue is what one of those must remember to use.

Which packets a phase registers, and in what order, is therefore the whole definition of that phase — so the next page is protocol phases, where nine codec tables — one per direction per phase, bar handshaking’s serverbound-only one — become the four languages a joining connection speaks in turn.

Where to look

Packet · PacketType · PacketFlow · StreamCodec · ByteBufCodecs · IdDispatchCodec · ProtocolInfo · ProtocolInfoBuilder · ProtocolCodecBuilder · GameProtocols · GamePacketTypes · FriendlyByteBuf · RegistryFriendlyByteBuf · VarInt · BundlerInfo · PacketBundlePacker · CustomPacketPayload · HashedStack · PacketReport


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