Setting up a dedicated server for a Unity Game

Table of contents:

  1. Introduction
  2. Goal
  3. Research
  4. Development
  5. Conclusion
  6. Sources

1. Introduction

This blogpost explains how to set up a network framework for the Unity Game Engine. First, some research will be provided. This research will clarify important decisions that were made throughout the project. Next, I’m going to show how the project was developed. Finally, I will discuss where this project will go from here in the future. Sources are provided at the end of the article.

Creating an online game is something I have wanted to do for a while. This is because I have been sitting on an idea for an online game for a long time. I have worked with network solutions in the past, Photon PUN 2 specifically. However, I always wanted to know how to set up a network framework myself. Therefore, this is the perfect opportunity to finally increase my knowledge on this topic.

2. Goal

The goal for the R&D phase of this semester is to set up a network framework for a Unity game. This framework should work for a small scale third person multiplayer game. Players can spawn into the game, move around, shoot and take damage.

All of this serves to increase my knowledge of networking.

3. Research

3.1. TCP vs UDP

When you are setting up network communication you will have to decide on which protocol you want to use for delivering packages. The options are TCP and UDP. Both have pros and cons and can be used in different instances. These will be discussed and compared in this section.

3.1.1. TCP

TCP is an internet protocol. With TCP the client must establish a connection with the server. This is done through a three-way handshake. This goes as follows: the client sends a SYN (synchronization) message to the server. Next, the server returns a SYN-ACK (synchronization acknowledgement) message to the client. Finally, the client sends back a ACK (acknowledgement) to the server (BasuMallick, 2022).

After the three-way handshake has succeeded the client-to-server connection has been established. The client can now use this connection to communicate with the server (BasuMallick, 2022).

A benefit of TCP is that it is reliable. If a data packet is sent, and the recipient does not acknowledge it before a certain time (timeout) has passed, the data packet will be sent again. This guarantees that no data packets will be lost in transmission. An acknowledgement will only be sent when the entire, uncorrupted data packet has been received (BasuMallick, 2022).

A big downside, however, is that TCP connections can be slow. TCP has more latency than UDP. This is caused by the three-way handshake taking up time and the interval that is used for timeouts (BasuMallick, 2022).

3.1.2. UDP

UDP is a connectionless internet protocol. Because there is no need to establish a connection the transfer speed of data is enhanced. UDP is therefor low latency, unlike TCP (Ashtari, 2022).

However, where TCP is very reliable UDP is not. It sends out data without the need to know if anyone receives it. It does not care to acknowledge the delivery of its datagrams or to check if they have arrived in the right order. Some data might be lost, corrupted or received improperly. Because of that, UDP is mostly used in programs where its speed is valued over the reliability of TCP. Examples of where UDP is used are video streaming and, most noteworthy, online gaming (Ashtari, 2022).

UDP is often used for gaming because it is unlikely that the loss of data is noticable to the player. Because a game is usually played with a framerate of 60 frames per second. The loss of data for a few these frames is therefor hard to notice. TCP’s latency, in comparison, is a lot more noticeable because of the data coming in late (Ashtari, 2022).

Because of UDP’s better performance in speed, and my project being a real time third-person shooter game, I have decided to build my framework for this project around UDP.

3.2. Multiplayer Network Models

Next, it’s important to know what network model is going to be used. This describes the architecture that will be used for communication between players. In this section I’m going to discuss three different models to decide which one is best for the game.

Before getting into these topics, it is good to know what is meant when referencing the terms “server” and “clients”. Below are the definitions as provided in the Unity Documentation

Server: The entity that holds the final say in what is the current state of the world.”

Client: An entity that shares their version of the game/world state to the server.”

3.2.1. Peer-to-peer

According to the Unity Documentation, the peer-to-peer model separates itself from the other two options by having no server involved at all. Instead, all clients communicate directly with each other. Because there is no server to make decisions on the state of the game, every device must run the game’s logic and negotiate events with others (Unity, n.d.).

Downsides of peer-to-peer models is that latency is typically high. It also has lower precision unless deterministic lockstep is used. Cheating is also easier to accomplish for clients if there is no deterministic lockstep involved (Unity, n.d.).

3.2.2. Client-hosted server

The Unity Documentation says that with a client-hosted server one of the clients will essentially become the server. This makes one player the owner of the world that the other players connect to (Unity, n.d.).

This makes latency unpredictable since it is dependant on the client-host’s network quality. The same is true for precision. Another issue that this model makes it easier to cheat. After all, one player has authority over the game (Unity, n.d.).

3.2.3. Dedicated game server

Unity Documentation explains that with a dedicated game server a separate entity is used to host the game. The dedicated server acts as the authority in the game and decides where everything and everyone is and what happens. Clients communicate only towards the server, and the server then communicates to all clients (Unity, n.d.).

All the logic is handled on the server, separated from the players. This makes it harder for players to cheat, because the server decides what happens instead of a client. It is also more secure. Clients are not directly communicating to each other and so are not exposing their IP to other players. Latency is also typically low, and precision high (Unity, n.d.).

Using a dedicated game server seems like the right choice for my project, since I like the benefits of having high cheat mitigation and safety. The low latency and high precision are also nice bonuses.

4. Development

4.1. Setting up UDP

To get started with using UDP I first had to initialize a few things:

	 ///<summary> data is used to store received bytes so they can be read later. 	</summary>
        data = new Byte[1024];

        ///<summary> remote is used to store the sender's endpoint. </summary>
        remote = new IPEndPoint(IPAddress.Any, 0);
    
        ///<summary> Initializes the server's endpoint. </summary>
        endPoint = new IPEndPoint(IPAddress.Any, listenPort);

        ///<summary> Opens the socket for UDP and binds the server's endpoint to it. 																																															This is required for UDP. </summary>
        socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
        socket.Bind(endPoint);

        Debug.Log("Waiting for a client...");

        ///<summary> Starts a new thread with the sole purpose of receiving messages. </summary>
        thread = new Thread(new ThreadStart(ReceiveData));
        thread.IsBackground = true;
        thread.Start();

4.2. Receiving data

With all that being set up the ReceiveData can now start running to receive the data:

 	private void ReceiveData()
    {
        while (true)
        {
            try
            {
                ///<summary> integer contains the number of bytes of the message received.
                ///remote is set to sender's endpoint. </summary>
                int recv = socket.ReceiveFrom(data, data.Length, SocketFlags.None, ref remote);

                ///<summary>Decodes the received data
                ///https://learn.microsoft.com/en-us/dotnet/api/system.text.encoding.getstring?view=net-8.0 </summary>
                string text = Encoding.UTF8.GetString(data, 0, recv);

                lock (receivedMessages)
                {
                    ///<summary> Incoming message is stored queue, alongside the sender's endpoint. </summary>
                    receivedMessages.Enqueue(new(text, remote));
                }
            }
            catch (Exception error)
            {                
                Debug.LogWarning(error.ToString());
            }
        }
    }

Incoming messages can now successfully be stored alongside the endpoint of the sender. Next, I need a method that can read the messages, since that hasn’t happened yet. This method will also take care of some safety precautions. It will filter out messages that were send with an invalid player ID. It will only accept a message if it has been sent by an ID matching with the endpoint stored in Clients. This means a client also can’t send messages with another player’s ID. Besides that, this method will ensure that messages that are deemed “important”, e.g., spawning players, taking damage, etc. are only handled once. It will filter out any duplicate messages. Finally, if a message is deemed valid it will call the appropriate method to handle the message’s data.

Note that these checks, except for filtering out duplicate messages, are solely present in the server side of this method. Clients are only listening for messages coming from the server’s endpoint so there is no need for them there.

private void ReadMessages()
    {
        lock (receivedMessages)
        {
            ///<summary> Stored messages are read from queue. </summary>
            while (receivedMessages.Count != 0)
            {
                Messages data = JsonUtility.FromJson<Messages>(receivedMessages.First().text);

                ///<summary> Checks if sender is already stored in clients. </summary>
                if (!clients.ContainsKey(data.id))
                {                   
                    if (data.id != 0)
                    {
                        Debug.LogWarning("Client is sending messages with unverified clientId. Ignoring message");
                        receivedMessages.Dequeue();
                        return;
                    }
                    ///<summary> Clients with ID 0 have not been assigned an ID yet and are only allowed to send RequestIDMessages. </summary>
                    else if (data.messageType != Messages.MessageType.RequestIDMsg)
                    {
                        Debug.LogWarning("Client is sending messages with unverified clientId. Ignoring message");
                        receivedMessages.Dequeue();
                        return;
                    }
                }
                else
                {
                    ///<summary> Checks if sender's endpoint matches with endpoint stored in clients </summary>
                    if (receivedMessages.First().remote.ToString() != clients[data.id].endPoint.ToString())
                    {
                        foreach (Clients client in clients.Values) 
                        { 
                            if (client.endPoint.ToString() == receivedMessages.First().remote.ToString())
                            {
                                Debug.LogWarning($"Client {client.id} is sending messages with other player's ID. Ignoring message");
                                receivedMessages.Dequeue();
                                return;
                            }
                        }
                        Debug.LogWarning($"Client is sending messages from different endpoint");
                    }
                }

                ///<summary> If incoming message has no messageId it is deemed unimportant and may always be read.
                ///If a message is of type ConfirmMsg it must always be read. </summary>
                if (data.messageId == 0 || data.messageType == Messages.MessageType.ConfirmMsg)
                {
                    ///<summary>Calls method stored in dictionary based on the messageType. </summary>
                    messageHandlers[(int)data.messageType](receivedMessages.First());
                }
                ///<summary> If message made it past the previous if statement that means it must be important. 
                ///Important messages must only be handled once. This checks if the message has already been handled. </summary>
                else if (!duplicates.ContainsKey(data.messageId))
                {
                    duplicates.Add(data.messageId, data);
                    messageHandlers[(int)data.messageType](receivedMessages.First());
                }
                ///<summary> The message has already been handled and can be ignored.
                ///Sends message to sender to notify they can stop sending this message. </summary>
                else
                {
                    serverSend.SendConfirm(data.messageId, receivedMessages.First().remote);
                }
                receivedMessages.Dequeue();
            }
        }
    }

As mentioned previously, after a message is verified the ReadMessages() method will call the appropriate method to handle the data for this message. That method handles the data like this:

   public void OnIDRequestReceived(ReceivedMessages _message)
    {
        ///<summary>Translates the received JSON back to Message values</summary>
        RequestIDMessage message = JsonUtility.FromJson<RequestIDMessage>(_message.text);

        if (server.clients.Count >= server.maxPlayers)
        {
            Debug.Log($"Someone tried to join, but server is FULL");
            return;
        }

        if (server.clients.Count == 0)
        {
            server.amountOfPlayers++;
            server.serverSend.AddNewClient(server.amountOfPlayers, message.username, _message.remote);
            return;
        }

        foreach (Clients clients in server.clients.Values)
        {
            if (clients.endPoint.ToString() == _message.remote.ToString())
            {
                Debug.Log("Client sent join request while already in game");
                return;
            }
        }

        server.amountOfPlayers++;

        ///<summary>Sends confirmation back to client</summary>
        server.serverSend.AddNewClient(server.amountOfPlayers, message.username, _message.remote);
        server.serverSend.SendConfirm(message.messageId, _message.remote);
    }

All the code shown above allows me to successfully receive, read and handle incoming UDP messages. It also guarentees the messages were send by a valid client, and are not duplicates.

4.3. Sending data

Of course, being able to receive data is useless without any data being send. Here I ran into an interesting problem. With UDP being an unreliable networking protocol, I couldn’t guarantee that messages that were send would also arrive. For some messages, like sending transform data, this isn’t really an issue. Positions are updated so frequently that one gap in data won’t really be noticeable.

The issue comes with the more important messages. For example, if a new player spawns in that message must arrive and be handled on every client. Otherwise, they’d essentially be playing against a ghost. After doing research I found that the best way to guarantee delivery with UDP is to build your own confirmation system. I divided messages between “important messages”; messages that require a confirmation, and ” unimportant messages”; messages that don’t require a confirmation.

Unimportant messages are sent like this:

  private void Send(Messages _message, EndPoint _endPoint)
    {
        string json = JsonUtility.ToJson(_message);
        try
        {
            Byte[] sendBytes = Encoding.UTF8.GetBytes(json);
            server.socket.SendTo(sendBytes, sendBytes.Length, SocketFlags.None, _endPoint);
        }
        catch (Exception error)
        {
            Debug.LogWarning(error.ToString());
        }
    }

This method takes in a message and an Endpoint of the client. It converts the message back to bytes and then sends the messages to the client. Again, take note that this method on the client side does not have an Endpoint as a parameter, since it only sends messages to the server.

Important messages require a bit more work. They are sent like this:

private void SendImportantMessage(Messages _message, EndPoint _remote)
    {
	///<summary>Assigns messageId to message</summary>
        server.impMsgIndex = UnityEngine.Random.Range(0, 1000000);
        _message.messageId = server.impMsgIndex;

	///<summary>Stores coroutine in a Dictionary so it can be accessed later.</summary>
        IEnumerator newRoutine =  SendImportantMessageRoutine(_message, _remote);
        server.importantMessages.Add(server.impMsgIndex, new(newRoutine, true));

        StartCoroutine(newRoutine);
    }

    private IEnumerator SendImportantMessageRoutine(Messages _message, EndPoint _remote)
    {
        ImportantMessages importantMessage = server.importantMessages[server.impMsgIndex];
        while (importantMessage.waiting)
        {
            for (int i = 0; i < 10; i++)
            {
                Send(_message, _remote);
            }
            yield return new WaitForSeconds(.1f);
        }
        yield break;
    }

The coroutine provided above will essentially send messages in bursts. However, the coroutine will stop once a confirmation has been received. When handling incoming messages duplicates are filtered out, meaning that the message will still only be handled once. However, in case the confirmation message that was send got lost, we still send back a confirmation message for every received message with this messageID. Since the original messages won’t stop being send until a confirmation has been received, this now makes sure that both the original message and the confirmation arrive.

After testing with the Clumsy tool I can confirm that important messages are still being successfully delivered, even with a package loss close to 100%.

4.4. Creating a game

Now that I have the ability to both receive and send messages and have added safety precautions to deal with the unreliability of UDP for both I can use this system to create a small game.

Using “important messages” players can be assigned a ClientID, their username can be shared with other players and their player can be spawned.

Using “unimportant messages” player input can be send to the server, so their position can be handled and send back there. Handling the position of the server prevents clients from cheating. A cheater still wouldn’t be able to adjust their movement speed, for example.

5. Conclusion

I was able to successfully create a small-scale online game. To do this, I used UDP to send and receive messages, while creating safety nets to deal with UDP’s unreliability. I also made sure the entire project was server authoritative. On this front, I have achieved my goal. I also learned lots about networking in the process and am quite pleased with that.

I would have liked to have implemented more if I had the time. This current system lacks the ability to deal with heavy amounts of lag, or desync, for example. In the future, I would like to make it so that things like that can be handled better.

6. Sources

Ashtari, H. (2022, August 17th). What is User Datagram Protocol (UDP)? Definition, Working, Applications, and Best Practices for 2022. Spiceworks. https://www.spiceworks.com/tech/networking/articles/user-datagram-protocol-udp/

Aslam, S. (2022, June 30th). Create a UDP Server in C#. Delftstack. https://www.delftstack.com/howto/csharp/csharp-udp-server/

BasuMallick, C. (2022, April 5th). What is TCP? (Transmission Control Protocol)? Definition, Model Layers, and Best Practices for 2022. Spiceworks. https://www.spiceworks.com/tech/networking/articles/what-is-tcp/

Unity. (n.d.). An Introduction to Multiplayer Network and Server Models. https://unity.com/how-to/intro-to-network-server-models