Greetings, everyone! If you’re here, it means you’re interested in creating your own Minecraft server core using the Go programming language. This article is a revamped version of a previous guide on creating a core, crediting the original author. However, since they transitioned to Rust, I’ve taken the baton to continue developing the core in Go. The original code contained numerous errors, mostly because it was stored on the author’s GitHub, which they cleared for Rust. I’ve restructured the system to keep all the critical components stored locally, excluding the library.
We will utilize JetBrains’ GoLand compiler. The Go version is 1.20.
For those using VS Code, download GoLand and install the Go compilation extension.
Here are the versions we’ll work with:
Go — 1.20,
Core — 1.12.2.
Let’s start by creating a project. Upon opening the project, you’ll encounter this:

In case of an error:
$GOPATH/go.mod exists but should not
If encountered, simply recreate the project. Literally, upon seeing this error — click File -> New Project.
For other instances, manually create the file.

Select Empty File and name it main.
P.S. There’s no real advantage in choosing other options since you’ll end up pasting my code anyway. An Empty File is a blank document you can create in a text editor, whereas a Simple Application would have a module and a main method. In both cases, the complete code will be merely three lines.
Open the Terminal tab at the bottom and type:
go get github.com/Tnze/go-mc@master
After which, in the terminal, you’ll see:
go: downloading github.com/Tnze/go-mc v1.19.4-0.20230417163417-4315d1440ce1
go: added github.com/Tnze/go-mc v1.19.4-0.20230417163417-4315d1440ce1
This indicates that the required library has been downloaded.
A go.sum file is also created in the project, storing all imported files with their hashes. You shouldn’t delete anything from it unless you intend to forcibly discard a library.
Now we have our main.go file. Let’s insert the code:
package main
// Import the necessary packages
import (
"github.com/Tnze/go-mc/net"
"log"
)
func main() {
// InitSRV - Launch Server Function
// Start the socket at address 0.0.0.0:25565
loop, err := net.ListenMC(":25565")
// Output any errors
if err != nil {
log.Fatalf("Server startup error: %v", err)
}
// A loop processing incoming connections
for {
// Accept connection or wait
connection, err := loop.Accept()
// If an error occurs - skip the connection
if err != nil {
continue
}
// Process connection asynchronously
go acceptConnection(connection)
}
}
As observed, we leverage net from go-mc for connectivity. Unlike in Distemi, our entire code appears within the main function, providing a program start point and indicating that main.go is the principal file to the compiler.
You might see the following line highlighted in red:go acceptConnection(connection)
This indicates a separate function in another file is necessary. Let’s create it, naming it accepter.go.
package main
import (
CyberCore "CyberCore/serverbound"
"github.com/Tnze/go-mc/net"
//server "github.com/Tnze/go-mc/server"
)
func acceptConnection(conn net.Conn) {
defer func(conn *net.Conn) {
err := conn.Close()
if err != nil {
return
}
}(&conn)
// Read the handshake package (HandSnake)
_, nextState, _, _, err := CyberCore.ReadHandSnake(conn)
// If an error occurs during reading, stop processing the connection
if err != nil {
return
}
// Process the next state (1 - ping, 2 - game)
switch nextState {
case 1:
acceptPing(conn)
default:
return
}
}
Now, there’s no issue with acceptConnection in main, and we’ve completed our work on this file. What needs changing in this code?
1) While importing, you may notice the line:
CyberCore “CyberCore/serverbound” We defined the variable CyberCore, by importing a file from the path CyberCore/serverbound. You can name this variable whatever you prefer, but when importing the serverbound file, ensure the path is accurate. For example:CyberCore "main/serverbound"
qwerty "project/serverbound"
Here, CyberCore, qwerty are variable names, and main, project refer to project names you’ve created. You’ll still see import errors in red, which we’ll address later. You’ll also notice lines highlighted:
_, nextState, _, _, err := CyberCore.ReadHandSnake(conn)andacceptPing(conn)The first line pertains to an import issue, but as mentioned, we’ll address this later. For now, let’s focus on the acceptPing error – it’s for a missing file. Let’s create it.
package main
import (
"encoding/json"
"log"
"CyberCore/config"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/net"
"github.com/Tnze/go-mc/net/packet"
"github.com/google/uuid"
_ "io/ioutil"
)
// Handle the ping connection (PingList)
func acceptPing(conn net.Conn) {
// Initialize packet
var p packet.Packet
// Ping or description, will process only 3 times
for i := 0; i < 3; i++ {
// Read packet
err := conn.ReadPacket(&p)
// Stop processing if error occurs
if err != nil {
return
}
// Process packet by type
switch p.ID {
case 0x00: // Description
// Send packet with list
err = conn.WritePacket(packet.Marshal(0x00, packet.String(listResp())))
case 0x01: // Ping
// Echo back the received packet
err = conn.WritePacket(p)
}
// Stop processing if error occurs
if err != nil {
return
}
}
}
// Define player type for PingList response
type listRespPlayer struct {
Name string `json:"name"`
ID uuid.UUID `json:"id"`
}
// Generate JSON string for description response
func listResp() string {
// Packet structure for response ( 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"`
}
// Set response data
list.Version.Name = "ULE #1"
list.Version.Protocol = int(config.ProtocolVersion)
list.Players.Max = 100
list.Players.Online = -1
list.Players.Sample = []listRespPlayer{{
Name: "Sample Player :)",
ID: uuid.UUID{},
}}
list.Description = config.MOTD
// Add server icon if available
faviconPath := config.GetFaviconPath()
if faviconPath != "" {
faviconBase64, err := config.GetFaviconBase64(faviconPath)
if err == nil {
list.FavIcon = "data:image/png;base64," + faviconBase64
} else {
log.Printf("Error fetching server icon: %v", err)
}
}
// Marshal structure to JSON bytes
data, err := json.Marshal(list)
if err != nil {
log.Panic("Error converting object to JSON")
}
// Return result as string converting from bytes
return string(data)
}
Encountered more errors, haven’t we? We’ll fix it all up. Now in accepter, acceptPing no longer displays an error. Okay, let’s continue working on the accepter file.
Create a new Directory named serverbound and add a new file handsnake.go for managing “handshakes”.
In it, we’ll currently only use nextState, as this first segment will only handle ping. Therefore, in handling connection types from the HandSnake, we only concern ourselves with 1, indicating a ping.
An essential component for the core operation is reading HandSnake, previously located at server/protocol/serverbound/handsnake.go. Everything within protocol-related directories divides into ServerBound (for server) and ClientBound (for client). We will, therefore, read HandSnake with the following content:
package serverbound
import (
"github.com/Tnze/go-mc/net"
"github.com/Tnze/go-mc/net/packet"
)
// ReadHandSnake - reads the HandSnake packet ( https://wiki.vg/Protocol#Handshake )
func ReadHandSnake(conn net.Conn) (protocol, intention int32, address string, port uint16, err error) {
// Packet variables
var (
p packet.Packet
Protocol, NextState packet.VarInt
ServerAddress packet.String
ServerPort packet.UnsignedShort
)
// Read the incoming packet or return without result if error occurs
if err = conn.ReadPacket(&p); err != nil {
return
}
// Read packet contents
err = p.Scan(&Protocol, &ServerAddress, &ServerPort, &NextState)
// Return the parsed results in familiar form (primitive types)
return int32(Protocol), int32(NextState), string(ServerAddress), uint16(ServerPort), err
}
Now when you replace CyberCore in the line:
_, nextState, _, _, err := CyberCore.ReadHandSnake(conn)
with whatever variable name you specified during import, all file errors will vanish.
Next, let’s address errors in acceptPing. It’s again an import problem. As mentioned earlier, replace CyberCore with your project name (or at least the folder name your project is in).
Let’s create a new Directory called config and add a new file basic.go. This will define default settings such as the protocol version (for version 1.12.2 — it’s 340) and the MOTD (Message of the Day), the text you see beneath the server name. I’ve also added a function to locate a 64×64 image for aesthetics.
To convert JSON from a structure, we employ json.Marshal which might output an error. Since it shouldn’t produce an error, should that happen, the program terminates with an error.
The code is as follows:
package config
import (
"encoding/base64"
"github.com/Tnze/go-mc/chat"
"io/ioutil"
)
var (
ProtocolVersion uint16 = 340
MOTD chat.Message = chat.Text("Test Core §aULE")
)
// Retrieve the path to the 64x64 icon in config directory
func GetFaviconPath() string {
return "config/icon.png"
}
// GetFaviconBase64 - Returns Base64-encoded server icon string.
func GetFaviconBase64(faviconPath string) (string, error) {
// Acquire the icon file
faviconData, err := ioutil.ReadFile(faviconPath)
if err != nil {
return "", err
}
// Encode to Base64
faviconBase64 := base64.StdEncoding.EncodeToString(faviconData)
return faviconBase64, nil
}
I’ve included support for custom images. My image is titled icon and is in png format. This image resides in the same directory as the basic.go file, namely the config folder.
For testing, click here:

Select Add new Configuration and click on Go build

Now, set the following details: Red arrow — assign a name to the configuration (optional). Blue arrow — switch the run kind to Directory (instead of Package).

Your final setup should look like this:

Press Apply and Ok, then hit Shift+F10. Upon successful execution, you’ll see the following console output:
GOROOT=C:\Users\Ukraine\go\go1.20 #gosetup
GOPATH=C:\Users\Ukraine\go #gosetup
C:\Users\Ukraine\go\go1.20\bin\go.exe build -o C:\Users\Ukraine\AppData\Local\JetBrains\GoLand2023.1\tmp\GoLand___go_build_awesomeProject1.exe . #gosetup
C:\Users\Ukraine\AppData\Local\JetBrains\GoLand2023.1\tmp\GoLand___go_build_awesomeProject1.exe
Launch Minecraft, add server 0.0.0.0:25565 to your server list
and you’ll see this

-1 – This value was set in acceptPing.go
Because why not?
What’s next?
World generation. Where else would we go next? And maybe adding player connections.