Behind the Scenes: The Art of Multiplayer Game Server Development (Part - 3)

Behind the Scenes: The Art of Multiplayer Game Server Development (Part - 3)

In the last part of this blog series, we're going to explore gaming backend technology, specifically how it keeps the game's status synchronized for all players. Our main focus will be on peer-to-peer game networks, where players connect directly with each other to play together.

P2P Network

In this section, we'll explore the various types of networks that can be created for gaming.

Master central server

In this setup, a central server plays a crucial role in managing connections and assigning player IDs to incoming peers. This approach is implemented to prevent race conditions, ensuring that multiple peers do not simultaneously assign player IDs to new entrants.

The central server also acts as a relay for communication between peers. In this scenario, peers do not directly interact with one another; instead, they solely communicate with the central master peer. Players connect to the master peer to participate in the game.

One key drawback of this approach is its vulnerability. If the master server experiences any issues or goes offline, it can disrupt the entire network.

Master peer for connection only

In this alternative setup, the master server's primary responsibility is to accept and manage connections, essentially serving as a gateway for incoming peers. All connected peers are responsible for broadcasting their game state to one another.

Synchronization

Ensuring that all players in a multiplayer game stay in sync is critical, especially considering network issues like latency that can quickly lead to divergence in gameplay experiences. To maintain a smooth and consistent gaming experience for everyone, there are several strategies available. Let's delve into some of these methods:

Central Server

In a central server model, all in-game actions such as movement and attacks are transmitted to a central server, which then broadcasts the updated game state to all connected players. In this setup, the client's primary responsibility is to serve as the user interface for the game.

However, a notable drawback of this approach is that players must wait for the central server's response after each action they initiate. This creates a laggy experience for gamers.

Central Server With Client Prediction

To address the latency issue, the central server with client prediction comes into play. Here, clients are empowered to predict actions locally and promptly render them for the player. Subsequently, when the central server's update is received, the client can adjust its action as necessary.

You may have encountered this phenomenon in games where an object suddenly teleports to a different location. This is a result of prediction followed by the correction of the object's position, a common technique used to minimize perceived lag.

P2P Communication

In peer-to-peer (P2P) games, players have the capability to communicate directly with one another, sharing their in-game actions and updates regarding the game state.

Code

Let's create code for our snake game where player states are continuously exchanged. In this game, every time a new frame is rendered, both peers send new events to update the game state.

Upon receiving the game state, a peer will update the position of the opponent's snake. Additionally, we will implement a basic prediction mechanism: the opponent's snake will continue moving in the same direction even if we haven't received any events from the opponent. This prediction helps ensure a smoother gaming experience for both players.

It's important to note that while we're implementing a simple form of prediction, more complex synchronization algorithms, such as state-rewind, and timestamp-based events, are used in more sophisticated games like CSGO.

here game state means position of snake and food

We can introduce another snake in our game structure (Please refer to part 1 and part 2 for snake and network layer info respectively)

type Game struct {
    opponentSnakeBody SnakeBody
    OpponentFoodPos   Part
}

Within the game loop, run a separate goroutine to continuously listen for events from the opponent and adjust the snake's position accordingly. Additionally, implement a separate function to check for collisions between both snakes. If a collision occurs, update the game state and send the updated information to the peer. This ensures that both players are in sync and that the game state is always up-to-date.


func (g *Game) Run() {

go g.updatePeerSnake() /// goRoutine to update peer snake position
    for {

        ...
        ...
        if checkCollision(g.snakeBody.Parts[len(g.snakeBody.Parts)-1:], g.OpponentFoodPos) {
            g.UpdateOFoodPos(width, height)
            longerSnake = true
            g.Score++
            oppFoodTaken = true
        }
        g.snakeBody.Update(width, height, longerSnake)

        newState := GameStateUpdade{
            FoodPos: g.FoodPos,
            Parts:   g.snakeBody.Parts,
            Xspeed:  g.snakeBody.Xspeed,
            Yspeed:  g.snakeBody.Yspeed,
            Width:   width,
            Height:  height,
        }
        g.Node.writeChannel <- newState // writingt he new state

        g.opponentSnakeBody.Update(width, height, longerSnake)
        drawParts(g.Screen, g.snakeBody.Parts, g.FoodPos, snakeStyle, defStyle)
        drawParts(g.Screen, g.opponentSnakeBody.Parts, g.OpponentFoodPos, oppSnakeStyle, defStyle)
        ....
        ....
    }
    ...
    ...
}

Here's a refined explanation of updating the peer's snake position. We are actively listening for new snake state updates. As soon as we receive a new state, we promptly adjust the snake's position. This updated position will then be reflected in the next iteration of the game loop.

func (g *Game) updatePeerSnake() {
    for {
        var newSnakeState GameStateUpdade
        tmo := <-g.Node.readChannel
        err := mapstructure.Decode(tmo, &newSnakeState)
        if err != nil {
            panic(err)
        }

        width, height := g.Screen.Size()
        normalizeLocation(&newSnakeState, width, height)
        g.opponentSnakeBody.Parts = newSnakeState.Parts
        g.opponentSnakeBody.Xspeed = newSnakeState.Xspeed
        g.opponentSnakeBody.Yspeed = newSnakeState.Yspeed
        g.OpponentFoodPos = newSnakeState.FoodPos

        if newSnakeState.OpponenetFoodPos == (Part{}) && newSnakeState.OpponenetFoodPos.X != 0 {
            g.FoodPos = newSnakeState.OpponenetFoodPos
        }
    }
}

Both players might be playing on different screen size so we also need to normalize the game object location for both clients.

In real games also, players play on different screen sizes, therefore position can never be absolute, it should be normalized for all screen size.

That's all for now. We've covered a small game with a minimal and straightforward backend. With these game terminologies that we have covered, you can explore something new. If your interest lies primarily in the backend aspects of multiplayer gaming, consider creating a simple terminal-based game that involves intricate interactions between players. Even in a small multiplayer game, such as one where multiple players control space shuttles attempting to eliminate each other, multiplayer game development can be very tricky and complex.

Happy Coding 🤡

GITHUB REPO = https://github.com/dhairya0904/Snake-P2P

Did you find this article valuable?

Support Dhairya's Blog by becoming a sponsor. Any amount is appreciated!