This example demonstrates how to create a Minecraft server that listens for connections, accepts clients, and handles packets from multiple players.
Complete Example
The following code shows a complete server implementation:
package main
import (
"fmt"
"github.com/sandertv/gophertunnel/minecraft"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
func main() {
// Create a minecraft.Listener with a specific name to be displayed as MOTD in the server list.
name := "MOTD of this server"
cfg := minecraft.ListenConfig{
StatusProvider: minecraft.NewStatusProvider(name, "Gophertunnel"),
}
// Listen on the address with port 19132.
address := ":19132"
listener, err := cfg.Listen("raknet", address)
if err != nil {
panic(err)
}
for {
// Accept connections in a for loop. Accept will only return an error if the minecraft.Listener is
// closed. (So never unexpectedly.)
c, err := listener.Accept()
if err != nil {
return
}
conn := c.(*minecraft.Conn)
go func() {
// Process the connection on another goroutine as you would with TCP connections.
defer conn.Close()
// Make the client spawn in the world using conn.StartGame. An error is returned if the client
// times out during the connection.
worldData := minecraft.GameData{}
if err := conn.StartGame(worldData); err != nil {
return
}
for {
// Read a packet from the connection: ReadPacket returns an error if the connection is closed or if
// a read timeout is set. You will generally want to return or break if this happens.
pk, err := conn.ReadPacket()
if err != nil {
break
}
// The pk variable is of type packet.Packet, which may be type asserted to gain access to the data
// they hold:
switch p := pk.(type) {
case *packet.Emote:
fmt.Printf("Emote packet received: %v\n", p.EmoteID)
case *packet.MovePlayer:
fmt.Printf("Player %v moved to %v\n", p.EntityRuntimeID, p.Position)
}
// Write a packet to the connection: Similarly to ReadPacket, WritePacket will (only) return an error
// if the connection is closed.
p := &packet.ChunkRadiusUpdated{ChunkRadius: 32}
if err := conn.WritePacket(p); err != nil {
break
}
}
}()
}
}
Key Components
1. Creating a Listener
The minecraft.ListenConfig is used to configure and create a listener:
name := "MOTD of this server"
cfg := minecraft.ListenConfig{
StatusProvider: minecraft.NewStatusProvider(name, "Gophertunnel"),
}
The StatusProvider determines what information is displayed in the Minecraft server list. The first parameter is the MOTD (Message of the Day) and the second is the server name.
2. Starting the Listener
Use the Listen method to start accepting connections:
address := ":19132"
listener, err := cfg.Listen("raknet", address)
if err != nil {
panic(err)
}
Port 19132 is the default Minecraft Bedrock port. Using :19132 listens on all network interfaces.
3. Accepting Connections
Accept incoming connections in a loop:
for {
c, err := listener.Accept()
if err != nil {
return
}
conn := c.(*minecraft.Conn)
go func() {
// Handle connection...
}()
}
Accept() only returns an error if the listener is closed, so you can safely assume any error means the server is shutting down.
4. Handling Connections Concurrently
Each connection is handled in its own goroutine:
go func() {
defer conn.Close()
// Handle packets...
}()
Always defer conn.Close() to ensure connections are properly cleaned up when the goroutine exits.
5. Starting the Game
Before the client can interact with the world, you must call StartGame():
worldData := minecraft.GameData{}
if err := conn.StartGame(worldData); err != nil {
return
}
The GameData struct contains all the information about the world, including:
- World spawn position
- Game rules
- Time of day
- Weather
- Available items and blocks
You’ll typically want to populate the GameData struct with actual world information. An empty struct uses default values.
6. Reading and Writing Packets
The packet handling loop is similar to the client example:
for {
pk, err := conn.ReadPacket()
if err != nil {
break
}
// Handle specific packet types
switch p := pk.(type) {
case *packet.Emote:
fmt.Printf("Emote packet received: %v\n", p.EmoteID)
case *packet.MovePlayer:
fmt.Printf("Player %v moved to %v\n", p.EntityRuntimeID, p.Position)
}
// Send response packets
p := &packet.ChunkRadiusUpdated{ChunkRadius: 32}
if err := conn.WritePacket(p); err != nil {
break
}
}
Server vs Client Packets
Some packets are server-specific:
packet.ChunkRadiusUpdated - Tells the client how many chunks to render
packet.SetTitle - Displays a title on the client’s screen
packet.PlaySound - Plays a sound for the client
packet.SpawnParticleEffect - Spawns particle effects
Refer to the protocol documentation to see which packets are sent by servers vs clients.
Best Practices
- Error Handling: Always check errors from
ReadPacket() and WritePacket()
- Resource Cleanup: Use
defer conn.Close() to ensure connections are closed
- Concurrency: Handle each connection in a separate goroutine
- Graceful Shutdown: Close the listener when your server needs to shut down
Next Steps