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

ยท

5 min read

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

In this part, we will create the networking layer to transfer game movements between peers. We will be using the lib-p2p library for setting up network nodes.

Network library

Lib-p2p is an excellent library for creating p2p networks. You can read the documentation about it, it offers many possible ways to network creation. We will be using 2 nodes that connect with each other using TCP transport.

Create node

To establish secure communication between network nodes, a cryptographic key pair is generated. Each node is identified by a unique "multiaddress." When adding a new node (host), you can configure various parameters, including the connection type (e.g., socket or QUIC) via libp2p's transport layer. This flexibility enables you to adapt the network to your specific needs. The default transport is TCP.

type Node struct {
    ListenHost, RendezvousString, ProtocolID string
    ListenPort                               int

    writeChannel chan interface{} /// channel to write data
    readChannel  chan interface{} // channel to read data
    isConnection chan bool
}
    r := rand.Reader
    prvKey, _, err := crypto.GenerateKeyPairWithReader(crypto.RSA, 2048, r)
    if err != nil {
        panic(err)
    }    

    sourceMultiAddr, _ := multiaddr.NewMultiaddr(fmt.Sprintf("/ip4/%s/tcp/%d", node.ListenHost, node.ListenPort))

    // Creates a new RSA key pair for this host.
    // libp2p.New constructs a new libp2p Host.
    // Other options can be added here.
    host, err := libp2p.New(
        libp2p.ListenAddrs(sourceMultiAddr),
        libp2p.Identity(prvKey),
    )

Peer Discovery

Now, how can we discover peers to connect with? There are various methods that can be used for peer discovery like

  • Multicast DNS - It is a protocol designed for discovering devices within the same local network. It works by enabling devices to broadcast their private IP addresses to others on the network. For example, when you connect your mobile phone to a smart TV, mDNS helps locate the TV's IP address, simplifying the connection process. This method is particularly useful in local network scenarios, such as home networks, where devices need to find each other without relying on a central server or external services.

  • Remote peers - Remote peers, often located outside the local network, cannot be reached directly using private IP addresses, which are limited to their respective private networks. To establish connections with remote peers, alternative methods like UDP hole punching and relay nodes come into play.

In this blog, we will just use a connection string to connect with the peer. You can explore other ways to connect with peers.

lib-p2p is used by The InterPlanetary File System for peer connection.

Setting up peer

We will use a multiaddress obtained from one machine to establish a connection with another machine. To obtain this multiaddress, you can use the following method.

    peerInfo := peerstore.AddrInfo{
        ID:    host.ID(), // host is libp2p host here
        Addrs: host.Addrs(),
    }
    addrs, _ := peerstore.AddrInfoToP2pAddrs(&peerInfo)

Now, we configure the stream handler for one of the network nodes. In this setup, we provide the protocol ID and a corresponding handler responsible for managing streams from connected peers. The protocol ID serves as an agreed-upon identifier, such as "ipfs/1.2," ensuring that both the client and the server understand and use the same protocol for communication.

    host.SetStreamHandler(protocol.ID(node.ProtocolID), node.handleStream)

We have two distinct operations in separate goroutines: one for reading data from a peer and another for writing data to the peer. This separation ensures that one operation does not block the other.

We use a read channel to receive data sent by the peer, and a write channel to listen for any outgoing data that needs to be written to the network stream.

func (node *Node) handleStream(stream network.Stream) {
    log.Debug().Msg("Got a new stream!")

    // Create a buffer stream for non-blocking read and write.
    rw := bufio.NewReadWriter(bufio.NewReader(stream), bufio.NewWriter(stream))

    go readData(rw, node.readChannel)
    go writeData(rw, node.writeChannel)
    node.isConnection <- true

    // 'stream' will stay open until you close it (or the other side closes it).
}

func readData(rw *bufio.ReadWriter, readChannel chan<- interface{}) {
    for {
        receivedData := readJSON(rw)
        log.Debug().Msg(fmt.Sprintf("Received data %+v", receivedData))
        readChannel <- receivedData
        rw.Flush()
    }
}

func writeData(rw *bufio.ReadWriter, writeChannel <-chan interface{}) {

    for {
        var data interface{} = <-writeChannel
        log.Debug().Msg(fmt.Sprintf("%+v chacha", data))
        dataBytes, err := json.Marshal(data)
        log.Debug().Msg(string(dataBytes))

        if err != nil {
            panic(err)
        }

        _, err = rw.Write(dataBytes)
        if err != nil {
            // fmt.Println("Error writing to buffer")
            panic(err)
        }
        err = rw.Flush()
        if err != nil {
            // fmt.Println("Error flushing buffer")
            panic(err)
        }
    }
}

Connect to peer

Let's establish a connection with the peer. Initially, we obtain the multiaddress from the first host and utilize it to connect to that specific host. In this step, we replicate the process of creating separate read and write channels for communication.

func (node *Node) connectWithPeer(host host.Host, peerAddress string) {

    ctx := context.Background()
    addr, err := multiaddr.NewMultiaddr(peerAddress)

    if err != nil {
        panic(err)
    }

    peer, err := peerstore.AddrInfoFromP2pAddr(addr)
    if err != nil {
        panic(err)
    }

    if err := host.Connect(context.Background(), *peer); err != nil {
        fmt.Println("Connection failed:", err)
        panic(err)
    }

    stream, err := host.NewStream(ctx, peer.ID, protocol.ID(node.ProtocolID))

    if err != nil {
        fmt.Println("Stream open failed", err)
        panic(err)
    } else {
        rw := bufio.NewReadWriter(bufio.NewReader(stream), bufio.NewWriter(stream))

        go writeData(rw, node.writeChannel)
        go readData(rw, node.readChannel)
        log.Debug().Msg(fmt.Sprintf("Connected to Peer %s", peer))
    }
}

Write and read from a peer

We can simply use the channels to read and write from a peer.

readFromStream := <-g.Node.readChannel
writeToStream -> g.Node.writeChannel

// to convert to struct
err := mapstructure.Decode(readFromStream, &obj)

We have successfully implemented the network layer for our game. In the next phase, we will leverage this layer to facilitate communication of the game state between peers, ensuring that the game remains synchronized for both players.

Happy Coding!

Did you find this article valuable?

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