Skip to main content
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

  1. Error Handling: Always check errors from ReadPacket() and WritePacket()
  2. Resource Cleanup: Use defer conn.Close() to ensure connections are closed
  3. Concurrency: Handle each connection in a separate goroutine
  4. Graceful Shutdown: Close the listener when your server needs to shut down

Next Steps