Building a multiplayer game with an authoritative server in Golang with Ebiten
In this post I want to show how you can build a very basic authoritative server-client architecture in Golang. To demonstrate this I will create a multiplayer game and we will be able to run it in 2 different windows (to simulate the 2 players). The game I will be building is the most simple version of Pong (no score, just the gameplay for demonstrative purposes). This can be further extended or replicated to fit your needs.
Authoritative Server
First of all, let’s understand what an authoritative server is.
If your game is single player only, then you don’t really mind if the player is cheating, because whatever they do affects only them and not other people. But in a multiplayer game you need to take measures to prevent cheating, otherwise the game will not be fun for the players on the receiving end.
To solve this, we can use the authoritative server architecture. As the name suggests, each client will connect to a server (the “authority”), which is responsible for the logic of the game. The clients will send the player input, the server will check/validate if the action is valid and respond.
In the above example, we see that the player has a position of (100, 100). The player wants to move 100 units to the right. This information is then sent to the server, where it is validated. In this case this is a valid move, so the server sends back the new position of the player (200, 100). If, for example, the player had a wall to his right and couldn’t move in that direction anymore, the server would notice this and send back the current position again (100, 100).
Setting up the project
We create a new directory (you can name it whatever you want) and initialize a go module inside. We then create two folders, namely “server” and “client”. We create a “server.go” and a “client.go” file respectively in each directory.
~$ mkdir authoritative-server-pong
~$ cd authoritative-server-pong/
~/authoritative-server-pong$ go mod init pong
go: creating new go.mod: module pong
~/authoritative-server-pong$ mkdir server client
~/authoritative-server-pong$
~/authoritative-server-pong$ touch server/server.go
~/authoritative-server-pong$ touch client/client.go
Your folder structure should now look like this:
.
├── client
│ └── client.go
├── go.mod
└── server
└── server.go
3 directories, 3 files
Creating the client
We will first start by creating the client. We will need structs for the paddles and ball. We will also create a struct called GameState that will hold all the game data. Lastly, we will add a struct called input. I like to keep the input handling separate from the game logic, and in this case it is easier for me when I’m sending the data to the server.
type Paddle struct {
X, Y float64
}
type Ball struct {
X, Y, VX, VY float64
}
type GameState struct {
Paddles [2]Paddle
Ball Ball
}
type Input struct {
Player int
Up bool
Down bool
}
We will also need some variables for general settings (some of these will be useful when we add in the networking).
var (
conn net.Conn
state GameState
player int
screenWidth = 640
screenHeight = 480
paddleWidth = 10
paddleHeight = 100
ballSize = 10
)
Finally, we will create a Game struct. If you’re not familiar with Ebiten, it requires you to create a struct that implements the Game interface, so it needs to have the following methods:
- Update() error {}
- Draw(screen *ebiten.Image) {}
- Layout(w, h int) (int, int) {}
First we define the struct:
type Game struct{}
Then we create the Update method:
func (g *Game) Update() error {
up := ebiten.IsKeyPressed(ebiten.KeyW)
down := ebiten.IsKeyPressed(ebiten.KeyS)
input := Input{Player: player, Up: up, Down: down}
encoder := json.NewEncoder(conn)
if err := encoder.Encode(input); err != nil {
return err
}
decoder := json.NewDecoder(conn)
if err := decoder.Decode(&state); err != nil {
return err
}
return nil
}
What we’re doing here is first checking if the W key (for up) or S key (for down) are pressed on the keyboard. Then we set the corresponding fields in the Input struct. The input struct also holds a field called Player, which we’re setting to the int variable player we defined above between all the other vars. This will let us differentiate between both players later (player 0 will be the left player and player 1 will be the one on the right).
After, we encode the input struct and send it as a json file through the connection that we will create. As we will see later, the connection will send back the game state, also encoded in json, so we need to decode that to be able to check the new data sent from the server.
Next is the Draw method:
func (g *Game) Draw(screen *ebiten.Image) {
ebitenutil.DrawRect(screen, float64(state.Paddles[0].X), float64(state.Paddles[0].Y), float64(paddleWidth), float64(paddleHeight), color.RGBA{255, 255, 255, 255})
ebitenutil.DrawRect(screen, float64(state.Paddles[1].X), float64(state.Paddles[1].Y), float64(paddleWidth), float64(paddleHeight), color.RGBA{255, 255, 255, 255})
ebitenutil.DrawRect(screen, state.Ball.X, state.Ball.Y, float64(ballSize), float64(ballSize), color.RGBA{255, 255, 255, 255})
}
Nothing special here: we’re just using the DrawRect method from ebitenutil to draw our two paddles and ball (which in our game will actually be a square).
Lastly, the Layout method:
func (g *Game) Layout(w, h int) (int, int) {
return screenWidth, screenHeight
}
This will just return the screenWidth and screenHeight variables we set in the beginning.
Now we can finally code the main function of client.go:
func main() {
var err error
conn, err = net.Dial("tcp", "localhost:8000")
if err != nil {
fmt.Println("Failed to connect to server:", err)
return
}
defer conn.Close()
fmt.Print("Enter player number (0 or 1): ")
fmt.Scanf("%d", &player)
ebiten.SetWindowSize(screenWidth, screenHeight)
ebiten.SetWindowTitle("Pong")
if err := ebiten.RunGame(&Game{}); err != nil {
log.Fatal(err)
}
}
The interesting part is setting up the network connection, the last part is boilerplate ebiten code for creating a window.
For this example we will just use localhost (I’m using port 8000, you can use any port you want). We first set up a TCP connection with net.Dial(). We then gather input from the user, as to which player is currently connecting (remember this is being sent to the server through the Input struct).
Lastly, we create the ebiten window. The function SetWindowSize will set the size of the window, but not the game. For example, in our case we are using 640×480 for the game size. If we instead do ebiten.SetWindowSize(screenWidth*2, screenHeight*2), this would scale up our game by a multiple of 2, to the size of the window being created (so 1080×960). We also set the window title and then we use ebiten.RunGame() to run it.
Don’t forget to run go mod tidy at the root of the project to make sure ebiten and ebitenutil are included in the project.
Note: Make sure to use github.com/hajimehoshi/ebiten/v2 and github.com/hajimehoshi/ebiten/v2/ebitenutil as the import paths in your files. The functions used in this tutorial are only compatible with v2 of ebiten.
The Server
Next up, it’s time to create our server, so let’s start editing the server.go file.
First of all, we need to create the same structs as in the client.go file, since the server will need to use them as well:
type Paddle struct {
X, Y float64
}
type Ball struct {
X, Y, VX, VY float64
}
type GameState struct {
Paddles [2]Paddle
Ball Ball
}
type Input struct {
Player int
Up bool
Down bool
}
We will also create a few variables that we will need later:
var state GameState
var mutex sync.Mutex
const (
screenWidth = 640
screenHeight = 480
paddleWidth = 10
paddleHeight = 100
ballSize = 10
speed = 5
)
I have chosen to split the server logic into three main parts:
- Updating the game
- Processing the input
- Handling the connection
We will start with updating the game, since this should be straightforward if you’ve done any 2D game programming before.
func updateGame() {
mutex.Lock()
defer mutex.Unlock()
state.Ball.X += state.Ball.VX
state.Ball.Y += state.Ball.VY
if state.Ball.Y < 0 || state.Ball.Y > screenHeight-ballSize {
state.Ball.VY *= -1
}
if state.Ball.X < 0 || state.Ball.X > screenWidth-ballSize {
state.Ball.X, state.Ball.Y = screenWidth/2, screenHeight/2
state.Ball.VX, state.Ball.VY = 4, 4
}
for i := range state.Paddles {
if state.Ball.X < state.Paddles[i].X+paddleWidth &&
state.Ball.X+ballSize > state.Paddles[i].X &&
state.Ball.Y < state.Paddles[i].Y+paddleHeight &&
state.Ball.Y+ballSize > state.Paddles[i].Y {
state.Ball.VX *= -1
}
}
}
Don’t forget to use mutex.Lock(), as we will be running the functions in goroutines later.
What we’re doing here is first updating the ball X and Y every frame according to it’s velocity. Then we’re checking if the ball is either crossing the top border or the bottom border of the screen: if it is, we change it’s Y-direction. Next, we have a check for the X-axis, seeing if the ball crosses either the left or right side of the screen. If it does, we put the ball in the center again so the game can restart. (If this project were to go into more detail, this is where one of the players would score.) The last if statement is making sure that the ball’s X-direction changes if it hits one of the paddles.
Secondly, we will write the function for processing input:
func processInput(input Input) {
mutex.Lock()
defer mutex.Unlock()
paddle := &state.Paddles[input.Player]
if input.Up && paddle.Y > 0 {
paddle.Y -= speed
}
if input.Down && paddle.Y < screenHeight-paddleHeight {
paddle.Y += speed
}
}
As you can see, this function is quite short. All it does is set the mutex.Lock() and move the paddles based on the player input. As mentioned in the client section, it checks for which player has given the input, so it moves the correct paddle. It will also not allow the paddles to move off-screen.
Lastly, we will handle the connection to the clients.
func handleConnection(conn net.Conn, player int, wg *sync.WaitGroup) {
defer wg.Done()
defer conn.Close()
decoder := json.NewDecoder(conn)
encoder := json.NewEncoder(conn)
for {
var input Input
if err := decoder.Decode(&input); err != nil {
fmt.Println("Player disconnected:", player)
return
}
processInput(input)
mutex.Lock()
if err := encoder.Encode(state); err != nil {
mutex.Unlock()
fmt.Println("Failed to send game state to player:", player)
return
}
mutex.Unlock()
}
}
We first start by making sure to defer the wg.Done and conn.Close() functions, since we don’t want our server to get stuck. Then we create encoder and decoder variables (which we also saw in the client code, used there for encoding the input and decoding the game state).
We then enter an unending loop. What happens here is that we first get the input from the players, pass it to our processInput() function, then we send over the new state to the clients. If there is an error either in getting the input or sending the state, the function will return and the server will shut down the game.
We will now combine all these functions to create the main function of server.go:
func main() {
state = GameState{
Paddles: [2]Paddle{
{X: 20, Y: screenHeight / 2},
{X: screenWidth - 40, Y: screenHeight / 2},
},
Ball: Ball{X: screenWidth / 2, Y: screenHeight / 2, VX: 4, VY: 4},
}
go func() {
for {
updateGame()
time.Sleep(16 * time.Millisecond)
}
}()
ln, err := net.Listen("tcp", ":8000")
if err != nil {
fmt.Println("Failed to start server:", err)
return
}
defer ln.Close()
fmt.Println("Server started on :8000")
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Failed to accept connection:", err)
continue
}
wg.Add(1)
go handleConnection(conn, i, &wg)
}
wg.Wait()
}
In the main function, we start by creating the game state (setting the paddle positions, and the ball position and velocity).
Then we enter a goroutine, where we will call updateGame(). Here we also call time.Sleep() every 16 milliseconds. Ebiten will be running the window in as close to 60FPS as possible on the client side, so we will try to mimic this behaviour by delaying the server every 16 milliseconds.
Then we set up a listening server over TCP on port 8000 for the clients to connect to.
We create our WaitGroup and we enter a loop (of 2 iterations, since we’re only expecting to have 2 players joining the game). Inside this loop we add our new connections to the WorkGroup and the handleConnection() function we defined earlier.
Result
That’s it! We have all the code necessary for running our project. To test it, we need three separate terminals. We first navigate to the server directory in the first terminal and run go run . . This will start up our server. Then, in both other terminals we navigate to the client directory and run the same commend. You should get 2 different windows, each one controlling one paddle. Once both players disconnected (closing the windows), the server stops running.
Summary
Hopefully the purpose and functionality of an authoritative server is clearer to you. In our example, the client is only responsible of doing the rendering and gathering the input from the players. It then sends this input to the server, which handles the whole logic. After it has done that, it sends the new state back to the clients.
Feel free to ask me if you have any questions. You can check out the complete code on my Github:
https://github.com/ssilatel/authoritative-server-pong
Thank you for reading!
Leave a Reply