Creating your server core in Go for Minecraft Java. Part #1 – Basic about the idea

The topic, which will be written in many parts, will mainly represent the stages of writing your own server core for the multiplayer of the popular Minecraft game.

About Minecraft

The game is currently very popular among many people, probably by going to the same YouTube you can find a lot of videos or streams on this game, people of different ages play it. You can play it in both single player and multiplayer modes. We will analyze specifically the multiplayer as it allows people to play together, where there are some problems, but also add variety to the game.

About game servers

The game itself does not have a centralized server system, which allows you to create servers anywhere, but more often hosting services are rented for them. Servers can be found very easily, for example, by typing “Monitoring minecraft servers” into the Google search engine, and you can also create your own. Most servers are based on cores, which in turn are based on a single core – Vanilla. Here is a small list of cores that are often used: Paper, Purpur, Spigot, Sponge, Glowstone.

Server software

All of the kernels listed above share the same problematic dependency – they are written almost entirely in Java. By itself, this language is cool, there are a lot of cool features, but in the case of huge projects, there are often problems, for example, with RAM consumption, and the processor is often secondary. A regular server, without add-ons (plugins), with the same 20-40 players, can easily use 1 or even 2 gigabytes of RAM, and what can we say that during long work without reboots, consumption can take more than 6GB of RAM. Therefore, there are people who are trying to create their own kernels, for example, on Rust one of these kernels is being developed. I plan to describe in parts the creation of my kernel, but already in Go.

Why Go?

I’ve tried different languages ​​for everything (yes, even PHP for servers…), but I found Go to be the coolest. I mainly like its simplicity, convenience and ecosystem, because there are many cool libraries. I would also highlight its performance, because it is very wonderful here.

What for?

Yes, just 😀 And to tell the truth, I just wanted to do something interesting not only for me, but also for others. And maybe it will even help someone… Well, of course, it will be very convenient to use it somewhere at home.

Before the beginning

In this post, I plan to do only the basics, so there will be more interesting things in the next part).

First I would like to clarify what I will use Go the latest version (at the time of post 1.17.5), as well as the editor GoLand by JetBrains and I look forward to your support 🙂
So far, at the very beginning, the server will only be supported on 1.12.2, because my super computing device works very poorly in a combination of GoLand + Minecraft 1.16.5 and higher.

Start

For now, the kernel will be called ULE, so the project will be initialized in GoLand, we create main.go as a launcher.

Project initialization
Project initialization

As a simplification, we will use one library for game protocol. It is very powerful, but all I need from it is the functionality for parsing messages and reading / writing packets, as well as a little processing of players. So let’s install it like this:

go get github.com/Tnze/go-mc@master
go.mod after adding go-mc
go.mod after adding go-mc

After that, this library will be automatically added to go.mod and we can use it in our code by accessing “github.com/Tnze/go-mc/”.

Now, for convenience, I will create a server directory and a server.go file in it with the following content:

package server

// Импортируем пакеты
import (
	"github.com/Tnze/go-mc/net"
	"log"
)

// InitSRV - Функция запуска сервера
func InitSRV() {
	// Запускаем сокет по адрессу 0.0.0.0:25565
	loop, err := net.ListenMC(":25565")
	// Если есть ошибка, то выводим её
	if err != nil {
		log.Fatalf("Ошибка при запуске сервера: %v", err)
	}

	// Цикл обрабатывающий входящие подключеня
	for {
		// Принимаем подключение или ждём
		connection, err := loop.Accept()
		// Если произошла ошибка - пропускаем соденение
		if err != nil {
			continue
		}
		// Принимаем подключение и обрабатываем его не блокируя основной поток
		go acceptConnection(connection)
	}
}

As we can see, for work we use net from go-mc for connections, and also accept them using our function acceptConnection, which is declared in server/accepter.go and its code is already like this:

package server

import (
	"github.com/Distemi/ULE/server/protocol/serverbound"
	"github.com/Tnze/go-mc/net"
)

func acceptConnection(conn net.Conn) {
	defer func(conn *net.Conn) {
		err := conn.Close()
		if err != nil {
			return
		}
	}(&conn)
	// Читаем пакет-рукопожатие(HandSnake)
	_, nextState, _, _, err := server.ReadHandSnake(conn)
	// Если при чтении была некая ошибка, то просто перестаём обрабатывать подключение
	if err != nil {
		return
	}

	// Обрабатываем следющее состояние(1 - пинг, 2 - игра)
	switch nextState {
	case 1:
		acceptPing(conn)
	default:
		return
	}
}

Here you can already notice that the list of inputs has its own package, in the server/protocol/serverbound folder, and there is already a handsnake.go file for “handshakes”, but before that, it’s worth disassembling the function code for accepting connections, in it we still we use only nextState when reading, since in the first part only ping will be ready and therefore in processing the connection type from Handsnake we only use 1 which means it’s a ping.

Next in line, we have a very important component in the operation of the kernel – reading Handsnake, which, as described, was located in server/protocol/serverbound/handsnake.go and everything that is in the directory associated with the protocol will of course be everything related to it and share everything on ServerBound (for the server) and ClientBound (for the client), therefore with such a division, we will have exactly handsnake reading with the following content:

package serverbound

import (
	"github.com/Tnze/go-mc/net"
	"github.com/Tnze/go-mc/net/packet"
)

// ReadHandSnake - чтение HandSnake пакета( https://wiki.vg/Protocol#Handshake )
func ReadHandSnake(conn net.Conn) (protocol, intention int32, address string, port uint16, err error) {
	// Переменные пакета
	var (
		p                   packet.Packet
		Protocol, NextState packet.VarInt
		ServerAddress       packet.String
		ServerPort          packet.UnsignedShort
	)
	// Читаем входящий пакет и при ошибке ничего не возращаем
	if err = conn.ReadPacket(&p); err != nil {
		return
	}
	// Читаем содержимое пакета
	err = p.Scan(&Protocol, &ServerAddress, &ServerPort, &NextState)
	// Возращаем результат чтения в привычной форме для работы(примитивные типы)
	return int32(Protocol), int32(NextState), string(ServerAddress), uint16(ServerPort), err
}

Well, with the written comments, I think I painted everything in some detail, and if you want to read for yourself about the structure of the package and why everything is so readable, you can check out the official source.

After reading the HandSnake package, we also decide what to do with it, and therefore in accepter.go, when processing the state at 1, we accept it as a ping in the function acceptPing, but accepting a ping can already be a problem for someone because of the large code, because here the entire ping function costs us in server/accepter_ping.go not 20 lines:

package server

import (
	"encoding/json"
	"github.com/Distemi/ULE/config"
	"github.com/Tnze/go-mc/chat"
	"github.com/Tnze/go-mc/net"
	"github.com/Tnze/go-mc/net/packet"
	"github.com/google/uuid"
	"log"
)

// Получаем пинг-подкючение(PingList)
func acceptPing(conn net.Conn) {
	// Инициализируем пакет
	var p packet.Packet
	// Пинг или описание, будем принимать только 3 раза
	for i := 0; i < 3; i++ {
		// Читаем пакет
		err := conn.ReadPacket(&p)
		// Если ошибка - перестаём обрабатывать
		if err != nil {
			return
		}
		// Обрабатываем пакет по типу
		switch p.ID {
		case 0x00: // Описание
			// Отправляем пакет со списком
			err = conn.WritePacket(packet.Marshal(0x00, packet.String(listResp())))
		case 0x01: // Пинг
			// Отправляем полученный пакет
			err = conn.WritePacket(p)
		}
		// При ошибке - прекращаем обработку
		if err != nil {
			return
		}
	}
}

// Тип игрока для списка при пинге
type listRespPlayer struct {
	Name string    `json:"name"`
	ID   uuid.UUID `json:"id"`
}

// Генерация JSON строки для ответа на описание
func listResp() string {
	// Строение пакета для ответа( https://wiki.vg/Server_List_Ping#Response )
	var list struct {
		Version struct {
			Name     string `json:"name"`
			Protocol int    `json:"protocol"`
		} `json:"version"`
		Players struct {
			Max    int              `json:"max"`
			Online int              `json:"online"`
			Sample []listRespPlayer `json:"sample"`
		} `json:"players"`
		Description chat.Message `json:"description"`
		FavIcon     string       `json:"favicon,omitempty"`
	}

	// Устанавливаем данные для ответа
	list.Version.Name = "ULE #1"
	list.Version.Protocol = config.ProtocolVersion
	list.Players.Max = 100
	list.Players.Online = 5
	list.Players.Sample = []listRespPlayer{{
		Name: "Пример игрока :)",
		ID:   uuid.UUID{},
	}}
	list.Description = config.MOTD

	// Превращаем структуру в JSON байты
	data, err := json.Marshal(list)
	if err != nil {
		log.Panic("Ошибка перевода в JSON из обьекта")
	}
	// Возращаем результат в виде строки, переведя из байтов
	return string(data)
}

Everything cost us 83 lines … Probably this is still very little, since a folder was allocated for some config which will soon contain the entire configuration, but for now let’s talk about our favorite ping. We will read the accepted ping connection no more than 3 times, as this should be enough for a regular client in most cases, but this will partially protect us from ping attacks .. partially … well, if it was not possible to send a packet, then we stop processing it, so each time we read the packet, we also find out its type, as follows:

And here is the most interesting thing, when the player pings, we just need to return the packet sent by the player, but already when describing, we have to generate JSON from our structure, but the generation itself goes in the function listResp, but for it we have a data structure listRespPlayer, which speaks partially for itself because it describes the player for the response, another structure in the response generation function itself is already much larger, which corresponds to minimum response standard. We also set default values ​​to the structure

	list.Version.Name = "ULE #1"
	list.Version.Protocol = config.ProtocolVersion
	list.Players.Max = 100
	list.Players.Online = 5
	list.Players.Sample = []listRespPlayer{{
		Name: "Пример игрока :)",
		ID:   uuid.UUID{},
	}}
	list.Description = config.MOTD

And here we can notice that some config is being accessed, and this is just at the root of the config/basic.go project:

package config

import "github.com/Tnze/go-mc/chat"

var (
	ProtocolVersion uint16       = 340
	MOTD            chat.Message = chat.Text("Тестовое ядро §aULE")
)

And it has some default values ​​​​by the type of protocol version (for 1.12.2, the protocol version is 340), as well as MOTD or what you see in the form of text under the name of the server.

To generate JSON from a structure, use json.Marshal, which can output an error, and since it should not output an error, then we end the program with an error.


Outcome

This is the end of the first part of the story about the self-written server core for Minecraft Java. All source code available on GitHub. And I will very much hope for your support.

As a result of the first part, we got:

Ping result
Ping result

At the same time, the kernel still uses 2.1MB of RAM, but it is worth considering that on Linux it will use much less, since the consumption size is indicated on Windows 11.

Thank you for reading the article and a new part dedicated to writing your kernel will be released soon! 🙂

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *