Multiplayer Framework

Leander van Grinsven, 500794728

Download Link Google Drive (UPDATED): https://drive.google.com/drive/folders/1yViUFUteosTSK1ac4wAXfbYI5BlqhGQY?usp=sharing

1 Introduction

For some time now I have the urge to make a multiplayer game in Unity. This project was the perfect opportunity to start making one.

My project started by doing research into existing frameworks to see how they work and to see if it is a better idea to work with them instead of building my own. In the end I decided it was best to make my own framework to suit my own needs.

The framework has to support a MMORPG style game where you connect to a world with other existing players doing their own thing. The clients have to communicate their position, rotation and player attributes such as their health and what enemy target they have selected.

In this report I will go over what I had to do in order to get this project off the ground and the progress made so far.

2 Concept

The multiplayer frameworks work with a connection to a central server that receives, processes and shares the information of a client to other clients currently connected.

Image 1: An illustration of clients connected to a central server.

During my research research I had to make a decision on the connection type between client and server. The choice was TCP or UDP.

TCP (Transmission Control Protocol) is a method to define how to establish and maintain a connection. TCP is a connection oriented protocol which means a connection is made and maintained until all connection ends have finished their exchanges. The protocol provides error detection such as transmitting packets again if the other side did not receive them and did not send a reply of confirmation. TCP also reorders packets into the correct order.

UDP (User Datagram Protocol) is a simpler and connectionless protocol. Unlike TCP, UDP does not provide error detection or packet reordering. The advantage of UDP is that it is faster and more efficient with less latency which is what is most important in games.

Image 2: A comparison of TCP and UDP

The best choice for this framework is UDP as we want low latency and the error detection can be done in the framework itself.

3 Existing frameworks

There are a number of existing frameworks for multiplayer in Unity. Three of the most popular are UNet which is from Unity themselves but has been deprecated. A replacement is in the works but at the moment not a viable solution for a long term project.

The second framework is Mirror. Mirror is a high level networking API for Unity and is a popular choice for MMO scale games but does not support it out of the box. There are modified versions of Mirror on the Unity Asset Store which you have to pay for. For that reason it is not a good idea to go for Mirror or variations of Mirror.

The third framework is called Photon Cloud. Photon takes a different path when it comes to server hosting. Photon has dedicated servers to make hosting easier. The problem is that if you want a larger scale game, you would have to pay monthly in order to prevent from hitting the limit of the free package.

As there is not a free viable solution ready to use, the best solution is to create a framework from scratch. The advantage is that you know how it works and how to expand the functionality yourself.

4 Network

4.1 Client/Server & sending/receiving data

The client and server have many things in common. The client sends information about itself to the server and receives information of other clients from the server. The server receives information from one or more clients and distributes it to the clients.

4.1.1 Client

The client is in Unity.

The client starts by instantiating the player into the scene with the name you entered in the InputField. The client sends a login message to the server with the name of the client and a DataIdentifier.LogIn to make sure the server understands that the message is to let the server know that there is a client that wants to exchange data. The message is an Object with three attributes.

The next step is to open a new UDP socket, define the IP of the server, in this case it is localhost. I must define a port to use when making a new IPEndPoint, in this case I am using port 30000.

We make a byte array that contains the message we send. The message is currently a string that must be converted to a byte array in the GetDataStream() function. The data is then sent by calling the BeginSendTo function. At the same time we also start listening for incoming messages from the server and we call an asynchronous function called ReceiveData.

public void MessageSend(string message)
    {
        try
        {
            // Initialise a packet object to store the data to be sent
            Packet sendData = new Packet();
            sendData.ChatName = this.name;
            sendData.ChatMessage = message;
            sendData.ChatDataIdentifier = DataIdentifier.Message;
 
            // Get packet as byte array
            byte[] byteData = sendData.GetDataStream();
 
            // Send packet to the server
            clientSocket.BeginSendTo(byteData, 0, byteData.Length, SocketFlags.None, epServer, new AsyncCallback(this.SendData), null);
        }
        catch (Exception ex)
        {
        }
    }

The code above shows how the clients send data to the server. The way the clients receive the data works in much the same way.

4.1.2 Server

The server is a standalone C# Forms Application so it is compatible if with clients written in Unreal Engine for example.

Image 3: The server forms view

The server sends and receives messages in much the same way as the clients. We start by making a Packet object which is the same as the Packet on the client. We receive the data asynchronously so we can receive multiple at the same time.

The server must identify the type of message it is. There are currently three types: LogIn, Message & LogOut. LogIn is when a new client connects and wants to be added to the clientList.

The next step is to send the data to all the clients in the clientList with the BeginSendTo function.

The last step is to start listening for more connections and messages.

If one of the steps fails there is a catch that will show a messagebox with a reason why it failed.

4.2 Byte array to string / String to byte array

public Packet()
        {
            this.dataIdentifier = DataIdentifier.Null;
            this.message = null;
            this.name = null;
        }
 
        public Packet(byte[] dataStream)
        {
            // Read the data identifier from the beginning of the stream (4 bytes)
            this.dataIdentifier = (DataIdentifier)BitConverter.ToInt32(dataStream, 0);
 
            // Read the length of the name (4 bytes)
            int nameLength = BitConverter.ToInt32(dataStream, 4);
 
            // Read the length of the message (4 bytes)
            int msgLength = BitConverter.ToInt32(dataStream, 8);
 
            // Read the name field
            if (nameLength > 0)
                this.name = Encoding.UTF8.GetString(dataStream, 12, nameLength);
            else
                this.name = null;
 
            // Read the message field
            if (msgLength > 0)
                this.message = Encoding.UTF8.GetString(dataStream, 12 + nameLength, msgLength);
            else
                this.message = null;
        }
 
        // Converts the packet into a byte array for sending/receiving 
        public byte[] GetDataStream()
        {
            List<bytedataStream = new List<byte>();
 
            // Add the dataIdentifier
            dataStream.AddRange(BitConverter.GetBytes((int)this.dataIdentifier));
 
            // Add the name length
            if (this.name != null)
                dataStream.AddRange(BitConverter.GetBytes(this.name.Length));
            else
                dataStream.AddRange(BitConverter.GetBytes(0));
 
            // Add the message length
            if (this.message != null)
                dataStream.AddRange(BitConverter.GetBytes(this.message.Length));
            else
                dataStream.AddRange(BitConverter.GetBytes(0));
 
            // Add the name
            if (this.name != null)
                dataStream.AddRange(Encoding.UTF8.GetBytes(this.name));
 
            // Add the message
            if (this.message != null)
                dataStream.AddRange(Encoding.UTF8.GetBytes(this.message));
 
            return dataStream.ToArray();
        }

To send and receive, the message has to in bytes which is stored in dataStream. The dataStream is split into three sections. What GetDataStream() does is convert the message to be sent from a string to bytes and put together in a byte array.

The Packet converts dataStream back into a readable string message. It first determines the length of the name of the client and message. The length of the name and message is 4 bytes (index 4 and 8). The name starts at index 12 and the message starts at index 12 + the length of the name.

4.3 Spawning and moving clients

if (started)
        {
            if (timer <= 0.0)
            {
                timer = tickRate;
                    players = playerData.Split('=');
 
                    for (int i = 0; i < players.Length; i++)
                    {
                        playerInfo = players[i].Split(' ');
 
                        if (playerInfo.Length == 8 && playerInfo[0!= this.name)
                        {
                            Vector3 MPPosition = new Vector3(float.Parse(playerInfo[1]), float.Parse(playerInfo[2]), float.Parse(playerInfo[3]));
                            Quaternion MPRotation = Quaternion.Euler(float.Parse(playerInfo[4]), float.Parse(playerInfo[5]), float.Parse(playerInfo[6]));
                            if (GameObject.Find(playerInfo[0]) == null)
                            {
                                Multiplayer = (GameObject)Instantiate(Resources.Load("Prefabs/MPPlayer"), MPPosition, MPRotation);
                                Multiplayer.name = playerInfo[0];
                            }
                            else
                            {
                                Multiplayer = GameObject.Find(playerInfo[0]);
                                Multiplayer.transform.position = MPPosition;
                                Multiplayer.transform.rotation = MPRotation;
                            }
                        }
                        playerData = "";
                    }
            }
            else
            {
                timer -= Time.deltaTime;
            }
        }

When a client receives data from the server that originate from other clients, it collects that data into a buffer called players which is a string array. The data will need to be split so that we can properly update each player that is in the scene already or instantiate when a new client has connected. The data for each player ends with a = character so the Split function watches for that character. The data that is split is put into another string array called playerInfo.

The next step is to get the position and rotation of this particular client by parsing the pieces of string text from the playerInfo array into floats. It then checks the scene if a player exists. If it does not exist, the game will instantiate a new player at the correct position and rotation. If the player does already exist, the position and rotation is updated.

4.4 Sending data

if (connected)
            {
                message = string.Format("{0:#.000} {1:#.000} {2:#.000} {3:#.000} {4:#.000} {5:#.000}", transform.position.x, transform.position.y, transform.position.z, transform.eulerAngles.x, transform.eulerAngles.y, transform.eulerAngles.z);
                playerClient.MessageSend(message);
            }

In order to send data, a string has to be formed. In this case it is sending position and rotation values with room for more data in the future. Each value is limited to 3 decimals. This choice was made because the amount of data would quickly increase without adding anything visible to the gameplay.

5 Results

5.1 Result

Video 1: Demonstration of connecting to the server and moving around

The result is that there is a successful connection between at least 2 clients and the server. The server and clients run at a 40 times per second update to reduce jitter and to keep the point of views on all clients the same.

5.2 Future reference

The current state of the framework is just a basis of what it is going to be. I wish I added more functionality now but figuring out how to make it work in the first place was a big challenge. There is a list of what I want to add:

Weapon System

The framework currently only sends the position and rotation of the client to the server. In order for a weapon system to work, the client will need to send more information such as the target and a command on when to fire the weapons. The rest of the actions are performed on client just like in a single player game.

Grid Map

The current map will run into a float point limitation eventually. The framework needs to have support to split the map into a grid so the map can be as big as it needs to be. Since the framework is going to be used in a space game, the map will be quite large.

Movement prediction

The framework currently does not have any prediction to make the movement smoother. The client and server are currently sending and receiving at 40 times per second. This is a lot of information that could increase latency and jitter the more clients connect to the server. The tickrate could be reduced by adding prediction to smooth out the movement in a better way while updating at 20 times per second or less.

5.3 Sources

Unity Mirror. Retrieved 30 march, 2021. https://mirror-networking.com/

Unity Photon Cloud. Retrieved 30 march, 2021. From https://www.photonengine.com/pun

6 Framework Updates

The framework has received some updates that greatly improve the efficiency, performance and functionality of the framework. The previous version was not good enough in terms of functionality and the way it performed was not good enough to pass the test. The updates it has received should bring it more in line of what it should have been.

6.1 Weapon System + Targeting

Weapons have been added to the players. This allows players to shoot at each other. Each player now has shields and hull armor which take damage when hit by a projectile. To see the shields and hull (health) of your target, you click with your mouse on your target. A new user interface will show on the bottom right with the stats of your target. A short video below shows how it works.

Video 2: Demonstration of the weapon system and targeting

6.2 Player Movement

In the previous version of the framework, the players had to send and receive 50 updates per second in order to make the movement smooth in everyone’s eyes. Since then the movement has been improved by making the foreign client calculate the movement it has to do between two positions and/or rotations. With the code below, the amount of updates per second has been reduced from 50 per second to only 5. This means the server and clients are sending 10 times less data which is less straining on the server especially. The server would start the fall behind when 4 or more clients would be connected at a time. With this improvement, the server should be able to handle more players.

Video 3: Demonstration of the new player movement with position and rotation being updated at 5 times per second.

The previous method was instantly changing the location and rotation of the external client. In order for that method to run smoothly, you need to run the function at lot more in order to make it seem smooth. The old function is given below.

else
{
    Multiplayer = GameObject.Find(playerInfo[0]);
    Multiplayer.transform.position = MPPosition;
    Multiplayer.transform.rotation = MPRotation;
}

The new method performs a Vector3.MoveTowards which calculates the course between 2 Vector coordinates and the Quaternion.RotateTowards which calculates the rotation between 2 Quaternions making the movement smooth with less updates per second.

IEnumerator OtherPlayerMovement(Vector3 MPPosition,Quaternion MPRotation)
    {
        Multiplayer = GameObject.Find(playerInfo[0]);
        float elapsedTime = 0;
 
        while (elapsedTime < tickRate)
        {
            Multiplayer.transform.position = Vector3.MoveTowards(Multiplayer.transform.position, MPPosition, Time.deltaTime * 10);
            Multiplayer.transform.rotation = Quaternion.RotateTowards(Multiplayer.transform.rotation, MPRotation, Time.deltaTime * 20);
            elapsedTime += Time.deltaTime;
            yield return null;
        }
    }

6.3 Server Connection Timeout

Video 4: Demonstration of the client not able to connect to the server.

6.4 Closing the Connection

In the previous version there was no function to disconnect from the server. This means that if the player would reconnect to the server, the server would crash as there would be two identical clients connecting. Now the client will send a message to the server which would close the socket on the client and remove the client from the list on the server.

void OnApplicationQuit()
    {
        CloseConnection();
    }
    void CloseConnection()
    {
        try
        {
            if (this.clientSocket != null)
            {
                // Initialise a packet object to store the data to be sent
                Packet sendData = new Packet();
                sendData.ChatDataIdentifier = DataIdentifier.LogOut;
                sendData.ChatName = this.name;
                sendData.ChatMessage = null;
 
                // Get packet as byte array
                byte[] byteData = sendData.GetDataStream();
 
                // Send packet to the server
                this.clientSocket.SendTo(byteData, 0, byteData.Length, SocketFlags.None, epServer);
 
                // Close the socket
                this.clientSocket.Close();
            }
        }
        catch (Exception ex)
        {
        }
    }

6.5 Updated future reference

The updated state of the framework is a better start of what the framework will become. The server and clients share more data and clients can now send commands between each other which makes the weapon system work which can be expanded to do more things. There are a number of things which should be added to the framework in the future.

Change player movement even more to support variable speed

At this moment the speed of the client is fixed. If you try to change it, you would cause desync as your speed on other clients would not match what you are doing.

Hide clients based on distance and map

A system needs to be made where data from clients that are far away or not on the same map are not in your scene and not sharing data until you are nearby them. The more clients that are connected, the more data and load on the client. This new system would optimize it and make it support more players in the end.

Implement more commands that perform different functions

The framework now supports sending commands to the server which sends it to the other clients. This is how the new weapon system works. This needs to be expanded/reworked to make more commands work.

Related Posts