Writing a Minecraft Protocol implementation in Kotlin

Gabi
6 min readDec 18, 2022
Andesite block

A Minecraft Server/Protocol project is very cool to practice concurrency, and tooling stuff, which is very cool and useful nowadays. This article shows how I started thinking of my Andesite library, and designed it.

The starting point is https://wiki.vg, which compiles everything we need for developing a Minecraft server for Java Edition. To open the server, We need to set up the Coroutines context and open a TCP server on port 25565 — The Minecraft default port:


// 1. Here we need the `runBlocking`, because it need to block the main
// thread listening on clients connections.
// 2. The SupervisorJob is useful to not let the server dies when something
// goes wrong on a child process
fun main(): Unit = runBlocking(SupervisorJob()) {
// This enables the `kotlinx.coroutines` debug mode, so we can see the
// coroutines' names on logging.
System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)

val server = aSocket(ActorSelectorManager(Dispatchers.IO))
.tcp()
.bind("0.0.0.0", 25565)

while (true) {
val socket = server.accept()

// Naming a coroutine name for each connection is useful when
// debugging, so you can see exactly is running here in this context
launch(CoroutineName("session/${session.socket.remoteAddress}")) {
// ...
}
}
}

We need to define utility functions to read the Protocol’s specific stuff, like UUID, Strings, and Var Ints.

public inline class VarInt(private val value: Int) {
// `+`, `-`, ... function implementations.

public fun toInt(): Int = value
}

internal inline fun writeVarInt(varint: VarInt, writeByte: (Byte) -> Unit) {
var value = varint.toInt()

while (true) {
if ((value and 0xFFFFFF80.toInt()) == 0) {
writeByte(value.toByte())
return
}

writeByte(((value and 0x7F) or 0x80).toByte())
value = value ushr 7
}
}

internal inline fun readVarInt(readByte: () -> Byte): VarInt {
var offset = 0
var value = 0L
var byte: Byte

do {
if (offset == 35) error("VarInt too long")

byte = readByte()
value = value or ((byte.toLong() and 0x7FL) shl offset)

offset += 7
} while ((byte and 0x80.toByte()) != 0.toByte())

return VarInt(value.toInt())
}

The approach of readByte and writeByte parameters are useful when we need to count the values’ sizes like when extending the server to read Anvil World or when writing/reading to/from different types like BytePacketBuilder/ByteWriteChannel and using multiplatform since the Ktor TCP server is only available in JVM target.

public fun BytePacketBuilder.writeVarInt(varint: Int): Unit = writeVarInt(VarInt(varint))

public fun BytePacketBuilder.writeVarInt(varint: VarInt): Unit =
writeVarInt(varint) { writeByte(it) }

public fun ByteReadPacket.readVarInt(): VarInt = readVarInt { readByte() }

public suspend fun ByteWriteChannel.writeVarInt(varInt: VarInt): Unit = writeVarInt(varInt) {
writeByte(it)
}

public suspend fun ByteReadChannel.readVarInt(): VarInt = readVarInt { readByte() }

Var-int is a variable-length data encoding a two’s complement signed 32-bit integer, read more about its implementation here.

public fun BytePacketBuilder.writeUuid(uuid: Uuid) {
writeLong(uuid.mostSignificantBits)
writeLong(uuid.leastSignificantBits)
}

public fun ByteReadPacket.readUuid(): Uuid {
val mostSignificantBits = readLong()
val leastSignificantBits = readLong()

return Uuid(mostSignificantBits, leastSignificantBits)
}

The UUID input/output implementation here is using directly the significant bits.

public fun BytePacketBuilder.writeString(string: String) {
val bytes = string.toByteArray()
writeVarInt(bytes.size)
writeFully(bytes)
}

public fun ByteReadPacket.readString(max: Int = -1): String {
val size = readVarInt().toInt()
return when {
max == -1 -> readBytes(size).decodeToString()
size > max -> error("The string size is larger than the supported: $max")
else -> readBytes(size).decodeToString()
}
}

So, now we need functions to write and read the Packet Format, which is going to be used a lot in a protocol. The approach that I decided to use is basically creating a data class that wraps the Socket.

public data class Session(val socket: Socket) {
private val input = socket.openReadChannel()
private val output = socket.openWriteChannel()

public suspend fun <A : Any> writePacket(
packetId: Int,
serializePacket: BytePacketBuilder.() -> Unit
) {
output.writePacket {
val packet = buildPacket {
writeVarInt(packetId)
serializePacket()
}

writeVarInt(packet.data.remaining) // Writes packet size
writePacket(packet) // Writes packet entire data
}
output.flush()
}

public suspend fun <A : Any> receivePacket(
deserializePacket: ByteReadPacket.() -> A,
): A {
val size = input.readVarInt()
val packet = input.readPacket(size.toInt())
val id = packet.readVarInt().toInt()

return deserializePacket(packet)
}
}

After implementing the utility functions and opening the server to listen to connections, We can start thinking about the connection flow

FSM handling connections to the server

This FSM handles the entire protocol, from the Status/Login to the Play State, but in this post, We are implementing just the Status state, which is responsible for showing the MOTD. So the FSM will be simplified as not having the entire state change of NS = 2:

FSM handling Status State

It’s very simple to implement the status using the previously made functions since the handshake is the only thing that we need to listen to and respond to.

// Define the Handshake packet class
public data class HandshakePacket(
val protocolVersion: VarInt,
val serverAddress: String,
val serverPort: UShort,
val nextState: VarInt,
)


// [...]
// So, in the listening implementation we need to create a session variable:
val session = Session(socket)

val handshake = session.readPacket {
val protocolVersion = readVarInt()
val serverAddress = readString()
val serverPort = readUShort()
val nextState = readVarInt()

HandshakePacket(protocolVersion, serverAddress, serverPort, nextState)
}

println(handshake)

if (handshake.nextState == 0) {
println("Will show MOTD")

// [...]
}

The Handshake packet is the main/essential packet for our protocol, it will start the connection, and provide information like the client protocol version — which is equivalent to the client’s version, the server address and port that the client is connecting to, and what’s the next step (1 = Status — that we want, 2 = Login — that currently will close the connection). Now we need to send the Response packet that will effectively show the MOTD.

// 1. We are defining the json structure for the packet, this packet relies on
// json to display the information.
// 2. Unlike when reading the packet, currently, is pointless to create a
// type uniquely for the packet
// 3. Here I'm using the `kotlinx.serialization` for the JSON

@Serializable
public data class Response(
val version: Version,
val players: Players,
val description: Description,
val favicon: String? = null,
)

@Serializable
public data class Description(
val text: String
)

@Serializable
public data class Version(
val name: String,
val protocol: Int,
)

@Serializable
public data class Players(
val max: Int,
val online: Int,
val sample: List<Sample> = emptyList(),
)

@Serializable
public data class Sample(val name: String, val id: String)

// [...]

val response = Response(
version = Version(name = "1.19.3", protocol = 761),
players = Players(max = 10, online = 0),
description = Description(text = "Hello, world"),
)

session.writePacket(0x00) {
writeString(Json.encodeToString(response))
}

Now, this is sufficient to show the MOTD, but the Ping bar won’t show the actual ping, so we need to define a Ping/Pong packet:

// 1. This will be used to send and receive
// 2. The payload is the current time in milliseconds
public data class PingPacket(val payload: Long)

// [...]

val ping = session.receivePacket {
PingPacket(System.currentTimeMillis())
}

session.writePacket(0x01) {
writeLong(ping.payload)
}

Now the server is working completely fine and we can see the MOTD:

MOTD working fine

When designing a library like Andesite, using “kotlinx.serialization” is a really good option for implementing the packet stuff without having to write it manually.

@Serializable
@SerialName("HandshakePacket")
@ProtocolPacket(0x00)
public data class HandshakePacket(
val protocolVersion: VarInt,
val serverAddress: String,
val serverPort: UShort,
val nextState: NextState,
) : JavaPacket

I designed some annotations that are useful to define the packet id, etc… To implement the serializer stuff, I used the Encoding API, the implementation can be resumed as reading/writing from a byte stream, in this case, the Ktor implementations.

// Determines packet id.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
public annotation class ProtocolPacket(val id: Int)

// Sets packet's field to decode with Json format.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.PROPERTY, AnnotationTarget.TYPE)
public annotation class ProtocolJson

// Sets packet's field to decode with Nbt format.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.PROPERTY, AnnotationTarget.TYPE)
public annotation class ProtocolNbt

// Sets packet's field to decode string with [max] size.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.PROPERTY, AnnotationTarget.TYPE)
public annotation class ProtocolString(val max: Int)

// Determines a number enum.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
public annotation class ProtocolEnum

// Determines a number value.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FIELD)
public annotation class ProtocolValue(val value: Int)

// Determines the int variant to the List or Enum.
@SerialInfo
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
public annotation class ProtocolVariant(val kind: Variant)

@Serializable
public enum class Variant {
VarInt, VarLong,
UByte, Byte,
UInt, Int,
}

The rest of the API and its implementation is here, and you can check the documentation here.

Thanks for the time reading, best regards! 😃

--

--