We love the Minecraft Protocol
In the Minecraft Room of the Loft, November 2, 2026
Minecraft uses a client/server model, which is what allows you to run your own server and have other players join it. This of course means that the server JAR is just listening over a wire for some data to come and for it to respond to that. That means you could write your own server!
This page is a love letter to the Minecraft protocol.
Well, there's not just one Minecraft protocol, of course. There are 777 of them at the time of writing. Well, 777 protocols for the Java edition of the game, Bedrock Edition has its own protocol number, which is at 2,225 right now, but it's not monotonic so I have absolutely no idea how many bedrock protocol versions there actually are!
But I'll be talking about the latest Java Edition protocol, 777 (mostly).
This protocol, and most others, is made of packets. A packet is some length-prefixed payload of bytes, sent over TCP. No, the boundaries of a Minecraft protocol packet are not the same as a TCP packet, and yes, Java Edition uses TCP and not UDP as Bedrock does...
Most, but not all, packets are made up of a list of "types"/"codecs", such as int, string, lpvec3, optional(varint), etc.
There's not really a set of "primitive" types, of course byte is the only type you actually need, and everything else could be written on top of that, but the logical primitives are roughly:
byte,ubyte,short,ushort,int,longfloat,doublebooleanvarint,varlongstring(length)nbtoptional(type)bitset,fixed-bitset(n)array(type, length)enum(type)either(type1, type2)lpvec3
And then the slightly less primitive types:
uuid(just 2 longs)position, a 64 bit integer with 26 bits for the x and z, and 12 bits for the yidentifier(just a string)prefixed-array(type), avarintlength followed by anarray(type, length)prefixed-optional(type), abooleanfollowed by anoptional(t), present if the boolean is true, missing if falseid-or-definition(type), avarintthat is either greater than 0, or 0 followed by atype(essentiallyvarint,optional(type))
And then there's one last type, entity-metadata, which is 1 or more ubytes, which if equal to 0xFF terminates the type, otherwise is followed by a varint, which is looked up in the fucking Entity Java inheritance structure, and then whatever it corresponds to, that type is encoded, and then the process repeats until a 0xFF. This is the only place in the protocol that uses a sentinel terminator instead of being length-prefixed :)
And everything in the protocol is built around these types. Some of them are self explanatory. varint and varlong are variable-length integers that use 7 bits of an octet byte to encode number data, and the eight bit to encode whether the number needs more bits to encode. These are not the same as protocol buffer varints and varlongs, and are very slightly different!
Strings always have a max length.
NBT is a custom JSON-like binary format used by Minecraft, yeah I'll get into that later.
Optionals either encode a type, or they don't. How are you supposed to know? Well just figure it out idk.
Bitsets are a prefixed-array(long) as created by the .toLongArray method on java.util.BitSet.
Fixed bitsets are a non-prefixed array(byte) as created by the .toByteArray method on java.util.BitSet...
Similar to optionals, the length of an array needs to be known by context.
Enums just encode a type and can map that encoded value to a logical value.
Either is just a byte 0/1, and then the left type if the byte is 1, and the right type if the byte is 0.
And then lpvec3 is just a vector type.
Great, now we can read and write types. Packets are just a varint length of their payload, not including the varint itself, a varint ID, which corresponds to what the packet is... kinda. And then the payload of the types of the packet. The packet format is out-of-band, so you just have to know it. That's how every packet works... Except one. Well, and also there are certain packets you can send to change this format in the middle of serializing it, but that's, we'll get into that.
The simplest thing you could do as a server is respond to the server list ping request, which is packet 0 in the Status phase.
The protocol is split into 6 phases: Handshaking, where you begin; Status, where you send the ping and status requests and responses; Login, where you connect the client; Configuration, where you "configure" the client; and Play, where most game events and exciting things happen. There's also Transfer, which has no packets and just puts you into login immediately. It exists purely so you can reject it if you don't want to transfer clients. The client could of course just not tell you its transferring and get around this check, so its just for intent.
In each phase, you have a set of "clientbound" and "serverbound" packets. They each have their own set of varint IDs (which I'll call "numeric IDs"), which count up from 0. So a packet can't be taken on its own, you have to know what phase its in, and what direction its going. So something like "ClientboundStatusPongResponsePacket" has some meaning. There is also a ClientboundPlayPongResponsePacket, which is the same packet, but has a different numeric ID, and is sent in the Play phase. These IDs aren't unique across protocol versions, in case you were wondering. They get shuffled around all the time, so they really shouldn't be hard-coded into your serialization code.
Fortunately though, not all packets that encode the same information encode it in the same format! For instance, there are 3 "Disconnect" packets, one in Login, one in Configuration, and one in Play. The Configuration and Play disconnect packets have different numeric IDs (0x02 and 0x20), but are encoded in the same way, using an NBT "text component", which is a format Minecraft uses for rendering and storing formatted text that can have colors and such. The Login disconnect packet uses this same format, but encodes it stringified JSON, instead of NBT. This is the only point in the protocol that that's used.
Anyways, the server list ping packet! A client who wants to know the player count and description of a server will send a handshaking packet with its phase being Status, the server then needs to switch the client into Status on its end before reading the next packet (oh boy yes), which is the serverbound status request packet, which has an empty payload (no fields). The server then needs to respond with a JSON object containing the player count, the description as a text component, and some other fields, most of them used by the client to change how the server displays in the server list. If the server receives this packet twice, its supposed to ignore it all but the first time.
If the client sends the status request packet and gets no response, after 30 seconds, it will send a packet with an ENTIRELY DIFFERENT PROTOCOL FORMAT that was used back in 2013, it starts with the byte 0xFE, and then the 13 year old status request and response packets. Which of course use a completely different format that doesn't work anything like I've described so far. Modern clients will send this packet, but of course old ones will as well, so you can respond and tell them your server doesn't work on their 13 year old version of the game.
Of course a client could also just skip this packet entirely and send the ping request packet, or that packet can get sent after the status request packet.
There's also an option to open a server to LAN, which BINDS A UDP PORT AT THE IP 224.0.2.60:4445, AND SENDS A STRING PACKET TO IT EVERY 1.5 SECONDS IN THE FORMAT OF [MOTD]{motd}[/MOTD][AD]{port}[/AD]. The client looks for those specific strings [MOTD], [/MOTD], [AD], and [/AD], and ignores everything else!
By the way, the entire Status phase is completely optional and isn't needed for Login working :)
As I mentioned earlier, you actually need to parse a packet and respond to it before you can even parse the previous packet! The protocol is stateful and awful like this.
I also mentioned that the packet format can change in the middle of running, and it does that through the set encryption and compression packets. The server can tell the client to enable encryption, and the client will respond confirming that it did so. Likewise, the server can also tell the client to enable compression, which it will do silently, without any acknowledgement. This means that the state of a client, as far as the server is concerned, is both dependent on the stream of serverbound and clientbound packets. If the client never sends an acknowledgement, how are you supposed to know when to start compressing clientbound packets? Well... You need to send the enable compression packet when you know you're at a point in the protocol where the client is waiting on you, and won't send anything to you. This means that clientbound and serverbound packet serialization is kinda synchronized, but also doesn't technically need to block on each other...
Well that's the simplest phase of the protocol. Hopefully I'll come back and write about the next 3 phases at some point :p