paint-brush
Unity Realtime Multiplayer, Part 2: TCP, UDP, WebSocket Protocolsby@dmitrii
3,915 reads
3,915 reads

Unity Realtime Multiplayer, Part 2: TCP, UDP, WebSocket Protocols

by Dmitrii IvashchenkoAugust 6th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

The Unity Networking Landscape in 2023 series continues with a focus on data transmission protocols in real-time multiplayer games. The OSI model's Transport Layer with TCP and UDP are explored for optimal data exchange between players. TCP ensures reliable data delivery but can lead to delays, while UDP offers faster transmission with potential data loss. WebSocket, an application-level protocol based on TCP, enables persistent bidirectional communication and is suitable for real-time multiplayer games. Code examples for TCP and UDP clients and servers, as well as WebSocket client and server, illustrate implementation approaches. The choice of protocol depends on game requirements - TCP for reliability, UDP for speed, and WebSocket for bidirectional communication. The next article will delve into organizing reliable data transmission using UDP.
featured image - Unity Realtime Multiplayer, Part 2: TCP, UDP, WebSocket Protocols
Dmitrii Ivashchenko HackerNoon profile picture


My series of articles on the Unity Networking Landscape in 2023 continues! Today's post will cover the data transmission protocols utilized in real-time multiplayer games.


Greetings everyone! I'm Dmitrii Ivashchenko, a Lead Software Engineer at MY.GAMES. We'll start with a brief overview of what protocols exist at different levels of network interaction.


Content Overview

  • OSI Levels
  • Transmission Control Protocol
    • TCP Client
    • TCP Server
  • User Datagram Protocol
    • UDP client
    • UDP Server
  • Websocket
    • Websocket Client
    • Websocket Server
  • Conclusion


OSI Levels

The OSI (Open Systems Interconnection) model is a conceptual model that characterizes and standardizes the functions of a communication system in terms of abstraction levels. This model was developed by the International Organization for Standardization (ISO) in 1978.

It consists of seven layers:


  1. Physical Layer: This layer deals with the transmission and reception of raw bits over the channel. Protocols at this layer describe the physical interface and the characteristics of the medium, including bit representations, transmission rates, physical cables, cards, and connector designs.


  2. Data Link Layer: This layer provides data transfer across the physical medium and handles errors that may occur at the physical level.


  3. Network Layer: This layer determines the path (routing) for data transmission between networks.


  4. Transport Layer: This layer manages message delivery between points and provides reliability if necessary.


  5. Session Layer: This layer manages session establishment, maintenance, and termination between users and applications.


  6. Presentation Layer: This layer ensures data independence from differences in data representation (encoding) on the sender and receiver sides.


  7. Application Layer: This layer includes protocols that have a direct relation to the applications that the user interacts with.



It's worth noting that each layer builds upon the previous one. Layers above the Transport layer (Session, Presentation, and Application) are highly specialized and cannot help us organize real-time multiplayer. Therefore, we have to stop at the Transport Layer and use its TCP and UDP protocols for optimal data exchange between players.


Transmission Control Protocol

TCP is a connection-oriented protocol, which means that communication occurs between two devices that establish a connection to exchange data. This protocol ensures reliability because it guarantees that all transmitted data will reach its destination in the correct order. If any data is lost during transmission, TCP will automatically retry the request until all data is successfully transmitted.


Establishing a connection involves the following steps:


  • The client and server create sockets for exchanging data over TCP.
  • The client sends a SYN (synchronization) segment to the server with a specified destination port.
  • The server accepts the SYN segment, creates its own socket, and sends a SYN-ACK (synchronization-acknowledgment) segment to the client.
  • The client accepts the SYN-ACK segment and sends an ACK (acknowledgment) segment to the server to complete the connection establishment process. A reliable two-way connection is now established.





TCP Client

The example below shows a basic implementation of a TCP client and can be extended to work with specific data and game logic.


The code connects to a server with a specified IP address and port, and then sends and receives data through the connection. A network stream is used for asynchronous data reception from the server.


using System;
using System.Net;
using System.Net.Sockets;
using UnityEngine;

public class TCPClient : MonoBehaviour
{
    private TcpClient tcpClient;
    private NetworkStream networkStream;
    private byte[] receiveBuffer;

    private void Start()
    {
			// Example: Connect to server with IP address 127.0.0.1 (localhost) and port 5555
        ConnectToServer("127.0.0.1", 5555); 
    }

    private void ConnectToServer(string ipAddress, int port)
    {
        tcpClient = new TcpClient();
        tcpClient.Connect(IPAddress.Parse(ipAddress), port);
        networkStream = tcpClient.GetStream();

        // Start asynchronous operation to receive data from the server
        receiveBuffer = new byte[tcpClient.ReceiveBufferSize];
        networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null);
    }

    private void ReceiveData(IAsyncResult result)
    {
        int bytesRead = networkStream.EndRead(result);
        byte[] receivedBytes = new byte[bytesRead];
        Array.Copy(receiveBuffer, receivedBytes, bytesRead);

        string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes);
        Debug.Log("Received from server: " + receivedMessage);

        // Continue the asynchronous operation to receive data
        networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null);
    }

    private void SendData(string message)
    {
        byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message);
        networkStream.Write(sendBytes, 0, sendBytes.Length);
        networkStream.Flush();
    }
}


The method ConnectToServer(string ipAddress, int port) establishes a connection to the server at the specified IP address and port. Data reception from the server is carried out in the method ReceiveData(IAsyncResult result), while data transmission is done in the method SendData(string message). The received data is output to the console using Debug.Log.


TCP Server

The code example below represents a simple TCP server in Unity. The code initializes the server, listens to the specified port, and accepts incoming connections from clients. After connecting to a client, the server sends and receives data through the network stream.


The method StartServer(int port) initializes the server on the specified port and begins listening for incoming connections. When a client connection is established, the HandleIncomingConnection(IAsyncResult result) method is executed, which receives data from the client and starts an asynchronous operation to receive data.


The received data is processed in the ReceiveData(IAsyncResult result) method. After receiving data from the client, the server can perform the necessary processing or send data back to the client.


The SendData(string message) method sends data to the client through the network stream. The data is converted into a byte array and sent to the client.


using System;
using System.Net;
using System.Net.Sockets;
using UnityEngine;

public class TCPServer : MonoBehaviour
{
    private TcpListener tcpListener;
    private TcpClient connectedClient;
    private NetworkStream networkStream;
    private byte[] receiveBuffer;

    private void Start()
    {
        // Example: Start the server on port 5555
        StartServer(5555); 
    }

    private void StartServer(int port)
    {
        tcpListener = new TcpListener(IPAddress.Any, port);
        tcpListener.Start();

        Debug.Log("Server started. Waiting for connections...");

        // Start accepting client connections asynchronously
        tcpListener.BeginAcceptTcpClient(HandleIncomingConnection, null);
    }
}


The HandleIncomingConnection method is used to handle an incoming client connection. After accepting a connection, it obtains a stream for data exchange with the client and initiates an asynchronous operation to receive data from the client.


Then, the method creates a buffer for receiving data, based on the buffer size received from the connected client. An asynchronous operation is launched to read data from the stream using the created buffer. After the read operation completes, the data will be passed to the ReceiveData method for further processing.


Also, the method launches another asynchronous operation to accept incoming connections for the possibility of accepting the next client.


When a client connects to the server, this method will be called to handle the connection, and the server will be ready to asynchronously accept the next clients.


private void HandleIncomingConnection(IAsyncResult result)
{
    connectedClient = tcpListener.EndAcceptTcpClient(result);
    networkStream = connectedClient.GetStream();

    Debug.Log("Client connected: " + connectedClient.Client.RemoteEndPoint);

    // Start asynchronous operation to receive data from the client
    receiveBuffer = new byte[connectedClient.ReceiveBufferSize];
    networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null);

    // Accept next client connection asynchronously
    tcpListener.BeginAcceptTcpClient(HandleIncomingConnection, null);
}


The ReceiveData method is used to process the received data from the client. After the data reading operation is completed, the method checks the number of bytes read. If the number of bytes is less than or equal to zero, this means that the client has disconnected. In this case, the method closes the connection with the client and terminates execution.


If the number of bytes read is greater than zero, the method creates a byte array for the received data and copies the read data into this array. Then, the method converts the received bytes to a string using the UTF-8 encoding and outputs the received message to the console.


private void ReceiveData(IAsyncResult result)
{
    int bytesRead = networkStream.EndRead(result);
    if (bytesRead <= 0)
    {
        Debug.Log("Client disconnected: " + connectedClient.Client.RemoteEndPoint);
        connectedClient.Close();
        return;
    }

    byte[] receivedBytes = new byte[bytesRead];
    Array.Copy(receiveBuffer, receivedBytes, bytesRead);

    string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes);
    Debug.Log("Received from client: " + receivedMessage);

    // Process received data

    // Continue the asynchronous operation to receive data
    networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null);
}

private void SendData(string message)
{
    byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message);
    networkStream.Write(sendBytes, 0, sendBytes.Length);
    networkStream.Flush();

    Debug.Log("Sent to client: " + message);
}


The SendData method is used to send data to the client. It converts the message string to a byte array using the UTF-8 encoding and writes this array to the network stream. After sending the data, the method clears the stream and outputs the sent message to the console.

Although reliability may sound like a big plus, this TCP feature can create problems in real-time multiplayer games. Data transmission in TCP can be slowed down by the mechanism of retransmission and flow control, which can lead to delays or "lags".


User Datagram Protocol

UDP is a simpler protocol that does not guarantee delivery or packet order. This makes it much faster than TCP, as it does not spend time establishing a connection or retransmitting lost packets. Due to its speed and simplicity, UDP is often used in network games and other applications that require real-time data transmission.



However, using UDP requires developers to manage data transmission more carefully. Since UDP does not guarantee delivery, you may need to implement your own mechanisms to handle lost packets or packets that arrived out of order.


UDP Client

This code demonstrates a basic implementation of a UDP client in Unity. The StartUDPClient method initializes the UDP client and connects it to the remote server specified by the IP address and port. The client starts receiving data asynchronously using the BeginReceive method and sends a message to the server using the SendData method.


using System;
using System.Net;
using System.Net.Sockets;
using UnityEngine;

public class UDPExample : MonoBehaviour
{
    private UdpClient udpClient;
    private IPEndPoint remoteEndPoint;

    private void Start()
    {
        // Example: Start the UDP client and connect to the remote server
        StartUDPClient("127.0.0.1", 5555);
    }

    private void StartUDPClient(string ipAddress, int port)
    {
        udpClient = new UdpClient();
        remoteEndPoint = new IPEndPoint(IPAddress.Parse(ipAddress), port);

        // Start receiving data asynchronously
        udpClient.BeginReceive(ReceiveData, null);

        // Send a message to the server
        SendData("Hello, server!");
    }

    private void ReceiveData(IAsyncResult result)
    {
        byte[] receivedBytes = udpClient.EndReceive(result, ref remoteEndPoint);
        string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes);

        Debug.Log("Received from server: " + receivedMessage);

        // Continue receiving data asynchronously
        udpClient.BeginReceive(ReceiveData, null);
    }

    private void SendData(string message)
    {
        byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message);

        // Send the message to the server
        udpClient.Send(sendBytes, sendBytes.Length, remoteEndPoint);

        Debug.Log("Sent to server: " + message);
    }
}

When data is received from the server, the ReceiveData method is invoked, which processes the received bytes and converts them into a string. The received message is then logged to the console. The client continues to receive data asynchronously by calling BeginReceive again.

The SendData method converts the message into bytes and sends it to the server using the Send method of the UDP client.


UDP Server

This code demonstrates a basic implementation of a UDP server in Unity. The StartUDPServer the method initializes the UDP server on the specified port and starts receiving data asynchronously using the BeginReceive method.


When data is received from a client, the ReceiveData method is invoked, which processes the received bytes and converts them into a string. The received message is then logged to the console. The server continues to receive data asynchronously by calling BeginReceive again.


using System;
using System.Net;
using System.Net.Sockets;
using UnityEngine;

public class UDPServer : MonoBehaviour
{
    private UdpClient udpServer;
    private IPEndPoint remoteEndPoint;

    private void Start()
    {
        // Example: Start the UDP server on port 5555
        StartUDPServer(5555);
    }

    private void StartUDPServer(int port)
    {
        udpServer = new UdpClient(port);
        remoteEndPoint = new IPEndPoint(IPAddress.Any, port);

        Debug.Log("Server started. Waiting for messages...");

        // Start receiving data asynchronously
        udpServer.BeginReceive(ReceiveData, null);
    }

    private void ReceiveData(IAsyncResult result)
    {
        byte[] receivedBytes = udpServer.EndReceive(result, ref remoteEndPoint);
        string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes);

        Debug.Log("Received from client: " + receivedMessage);

        // Process the received data

        // Continue receiving data asynchronously
        udpServer.BeginReceive(ReceiveData, null);
    }

    private void SendData(string message, IPEndPoint endPoint)
    {
        byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message);

        // Send the message to the client
        udpServer.Send(sendBytes, sendBytes.Length, endPoint);

        Debug.Log("Sent to client: " + message);
    }
}


The SendData method takes a message and an IPEndPoint representing the client's address and port. It converts the message into bytes and sends it to the client using the Send method of the UDP server.


In the context of game development, the choice between TCP and UDP largely depends on the type of your game. If your game requires reliable data delivery and response time is not a critical factor (for example, in real-time strategy or turn-based games), then TCP may be a suitable choice. On the other hand, if your game requires fast data transmission and can handle some data loss (for example, in first-person shooters or racing games), then UDP may be the best choice.


WebSocket

WebSocket is a communication protocol that allows the establishment of a persistent connection between a browser and a server. The main difference from regular HTTP is that it enables bidirectional communication; the server is able, not only to respond to client requests, it can also send messages to it.


WebSocket is an application-level protocol based on TCP. It supports messages, not streams, which distinguishes it from regular TCP. WebSocket includes additional functionality that can add some performance overhead.


Here's how it works, step by step:


  • The client sends a special HTTP request called an "Upgrade request." This request informs the server that the client wants to switch to WebSocket.


  • If the server supports WebSocket and agrees to switch, it responds with a special HTTP response that confirms the upgrade to WebSocket.


  • After exchanging these messages, a persistent, bidirectional connection is established between the client and the server. Both sides can send messages at any time, not just in response to requests from the other side.


  • Now the client and server can send and receive messages at any time. Each WebSocket message is wrapped in "frames" that indicate when the message starts and ends. This allows the browser and server to correctly interpret messages, even if they arrive in a mixed order or are split into parts due to network issues.




Either side can close the connection at any time by sending a special "close connection" frame. The other side can respond with a confirmation of the closure, and after that, both sides must immediately stop sending any other data. Status codes can also be used to indicate the reason for closure.

WebSocket Client

The code below provides an example of implementing a WebSocket client in Unity using C# language and the WebSocketSharp library.


In the Start() method, which is called upon object initialization, a new instance of WebSocket is created, initialized with the address of your WebSocket server.


After that, event handlers OnOpen and OnMessage are set up.


using UnityEngine;
using WebSocketSharp;

public class WebSocketClient : MonoBehaviour
{
    private WebSocket ws;

    void Start()
    {
        ws = new WebSocket("ws://your-websocket-server-url/Auth");

        ws.OnOpen += OnOpenHandler;
        ws.OnMessage += OnMessageHandler;

        ws.ConnectAsync();
    }

    private void OnOpenHandler(object sender, System.EventArgs e)
    {
				var data = "Player1";
				ws.Send(data)
    }

    private void OnMessageHandler(object sender, MessageEventArgs e)
    {
        Debug.Log("WebSocket server said: " + e.Data);
    }
}


OnOpen is triggered when a connection is established with the server. In this example, when the connection is established, a message with the text "Player1" is sent to the server.


OnMessage is triggered when a message is received from the server. Here, upon receiving a message, its content is displayed in the console.


Then, the ConnectAsync() method is called, which asynchronously connects to the server.


WebSocket Server

The code below is an example of creating a WebSocket server.


In the Start() method, which is called when the object is initialized, a new instance of WebSocketServer is created, initialized with the address of your WebSocket server. Then the AuthBehaviour WebSocket service is added to the server, available at the /Auth path. After that, the server is started using the Start() method.


using UnityEngine;
using WebSocketSharp;
using WebSocketSharp.Server;

public class WebSocketServer : MonoBehaviour
{
	void Start()
  {
    var socket = new WebSocketServer("ws://your-websocket-server-url");
    socket.AddWebSocketService<AuthBehaviour>("/Auth");
    socket.Start();
  }
}

public class AuthBehaviour : WebSocketBehavior
{
  protected override void OnMessage (MessageEventArgs e)
  {
      var playerName = e.Data;
      Debug.Log("WebSocket client connected: " + playerName);
			Send("Auth Complete: " + playerName);
  }
}


AuthBehaviour is a class derived from WebSocketBehavior that defines the server behavior when receiving messages from clients. Here, the OnMessage() method is overridden, which is called when the server receives a message from a client.


In this method, the message text is first extracted, then a message is output to the console indicating which client has connected, using the name passed in the message. Then, the server sends a message back to the client containing information about the completion of the authentication.


Conclusion

We've discussed how to create connections for Unity games using TCP, UDP, and WebSockets, as well as the advantages and disadvantages of each. Despite the lightweight nature of UDP and the reliability of TCP, neither is a good choice for organizing real-time multiplayer in games. UDP will lose some data, and TCP will not provide the necessary transmission speed. In the next article, we will discuss how to organize reliable data transmission using UDP.