paint-brush
Unity Realtime Multiplayer, Part 5: Preparing Game Databy@dmitrii
795 reads
795 reads

Unity Realtime Multiplayer, Part 5: Preparing Game Data

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

Too Long; Didn't Read

In this article, Dmitrii Ivashchenko, Lead Software Engineer at MY.GAMES, discusses data preparation for network transmission in Unity Realtime Multiplayer. The focus is on data formats and techniques for serialization and deserialization. The article covers considerations like performance, memory, security, and version compatibility. JSON, Binary Serialization, Google's ProtoBuf, and FlatBuffers are explored as serialization formats. The article also delves into the decision between synchronizing game state and transmitting user input in multiplayer games, highlighting their implications and implementation considerations. The next article in the series will discuss network topologies in real-time multiplayer games.

People Mentioned

Mention Thumbnail
featured image - Unity Realtime Multiplayer, Part 5: Preparing Game Data
Dmitrii Ivashchenko HackerNoon profile picture


In terms of network transmission, in a realtime multiplayer game, data format is key. We cover techniques for serializing and deserializing data, including formats and libraries. We answer questions like these: Which data should be transmitted? In which situations? Read for answers.


Hello everyone, I'm Dmitrii Ivashchenko, Lead Software Engineer at MY.GAMES. We're continuing our article series on Unity Realtime Multiplayer in 2023, and this time we'll discuss preparing game data for network transmission.



What is the preferred format for passing data?

We've already taken a deep dive and seen how networks are structured and how they function, including a look at the various limitations and peculiarities we must deal with.


However, the exchange of information relies on more than just network infrastructure; data format must also be considered. So, next up, we'll see how to transmit data over a network efficiently — and how to avoid any pitfalls in the process.


Serialization and deserialization

Serialization and deserialization are critical elements for implementing real-time multiplayer games. These operations are used to convert game data into a format that can be transmitted over the network and back again.


  • Serialization is the process of converting an object's state into a byte stream for transmission over a network, for saving to memory, or for sharing between different services or processes.


    In the context of multiplayer games, serialized data would include data about the current state of the game, such as the position and status of players, the state of the game world, and so on.


  • Deserialization is the reverse process where the serialized byte stream is converted back into objects in memory.


Both play an important role in multiplayer games with constant data exchange between clients and servers, and we need to consider several aspects of this process:


  1. Performance: Serialization and deserialization must be as fast and efficient as possible to avoid delays. Choosing an appropriate serialization method is particularly important: some methods (such as JSON or XML) are easily readable and writable by humans, but they are usually less efficient compared to binary serialization.


  2. Memory: Serialized data must occupy as little memory as possible to reduce the load on the network and players' devices. Data compression can help reduce the size of transmitted data, but it can also increase the time required for serialization and deserialization.


  3. Security: Serialization and deserialization processes must be protected from manipulation and attacks. (For example, deserializing unreliable data can lead to security vulnerabilities.)


  4. Version compatibility: Game data may change over time, so it is important to manage version changes in the serialization and deserialization process. Mechanisms for managing version changes can vary depending on the serialization method used.


JSON

JSON is one of the most popular formats for exchanging data between a client and a server, and it's supported by almost every programming language (including C#). To work with JSON in Unity, you can use the JsonUtility classes from the UnityEngine namespace.


Let's see how it's done.


First, let's create a data class:

[System.Serializable]
public class PlayerData
{
    public string playerName;
    public int score;
}


Next, we can use the ToJson and FromJson methods for serialization and deserialization, respectively.

public void ExampleUsage()
{
    // Create an instance of player data
    PlayerData player = new PlayerData
    {
        playerName = "TestPlayer",
        score = 5000
    };

    // Serialize player data into JSON
    string json = JsonUtility.ToJson(player);
    Debug.Log(json);  // Output: {"playerName":"TestPlayer","score":5000}

    // Decerialize JSON back to player date
    PlayerData deserializedPlayer = JsonUtility.FromJson<PlayerData>(json);
    Debug.Log(deserializedPlayer.playerName);  // Output: TestPlayer
    Debug.Log(deserializedPlayer.score);  // Output: 5000
}


In this example, we create an instance of the PlayerData class, serialize it to a JSON string, and then deserialize it back into a PlayerData object.


Binary Serialization

Moving on, binary serialization is preferable to JSON serialization for several reasons. It is more efficient in terms of performance and resource utilization since data is transformed into a compact binary format. This results in faster processing time and less data volume for storage and transmission over the network.


Additionally, unlike JSON, which is easily readable and editable and is not suitable for storing and transmitting sensitive data, binary data is more difficult to read and modify, making it more secure for storing sensitive information.


In Unity and C#, you can use the BinaryFormatter and MemoryStream classes from the System.Runtime.Serialization.Formatters.Binary and System.IO namespaces, respectively. Let's take a look at an example:


using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

public static byte[] Serialize(object obj)
{
    BinaryFormatter bf = new BinaryFormatter();
    using (var ms = new MemoryStream())
    {
        bf.Serialize(ms, obj);
        return ms.ToArray();
    }
}

public static T Deserialize<T>(byte[] byteArr) where T : class
{
    using (var memStream = new MemoryStream())
    {
        var binForm = new BinaryFormatter();
        memStream.Write(byteArr, 0, byteArr.Length);
        memStream.Seek(0, SeekOrigin.Begin);
        var obj = (T) binForm.Deserialize(memStream);
        return obj;
    }
}


In this example, Serialize() takes an object and converts it into an array of bytes. Deserialize<T>() takes an array of bytes and restores it into an object of type T.


Here are some examples of using these functions:

public void ExampleUsage()
{
    PlayerData player = new PlayerData
    {
        playerName = "TestPlayer",
        score = 5000
    };

    byte[] serializedPlayer = Serialize(player);
    PlayerData deserializedPlayer = Deserialize<PlayerData>(serializedPlayer);

    Debug.Log(deserializedPlayer.playerName);
    Debug.Log(deserializedPlayer.score);
}


Here, we've created an instance of the PlayerData class, serialized it, and then deserialized it back into a PlayerData object. (Note that the PlayerData class must be marked as [Serializable]. This is important because the BinaryFormatter can only serialize classes marked with this attribute.)


ProtoBuf

Google's Protocol Buffers (ProtoBuf) is a binary serialization format designed to be much more compact, faster, and easier to use compared to standard formats such as JSON or XML. It not only provides efficient data serialization and deserialization, but it also speeds up data exchanges by reducing the amount of data transmitted.


ProtoBuf is particularly useful in situations where response time and efficient resource utilization are critical factors, such as inter-server communication or real-time transmission of large amounts of data.



FlatBuffers

Another widely used solution for data serialization is FlatBuffers. FlatBuffers is an efficient binary serialization format created by Google that provides fast and easy serialization and deserialization of structured data. The FlatBuffers protocol was designed with the features of real-time multiplayer games and applications in mind and offers several advantages over ProtoBuf.


One of the key advantages of FlatBuffers over ProtoBuf is that it allows direct access to serialized data without prior deserialization. This means that you don't have to spend processor time deserializing entire blocks of data if you only need to extract certain parts of the data.


Unlike ProtoBuf, FlatBuffers supports trees and graphs of structured data, making it a more flexible option for complex data structures. Although ProtoBuf and FlatBuffers have a lot in common, FlatBuffers may be the better choice for projects that require high performance and efficiency when working with large and complex data structures.


Transmission of State and Input

Developing a multiplayer game involves making many decisions, and one of the most important is choosing between synchronizing the game state and transmitting user input.


State Transmission

Game state transmission involves the control node (or server) receiving a command from the player processing it, and then transmitting the new game state to all participants.


For example, player A sends the MoveTo(1, 2) command to the server. The server receives the request, processes it, and informs all players that player A is now at point (1, 2). Player A, upon receiving the response from the server, moves to the new position, and the character of player A is also moved to the new position on the screens of other players.





In this approach, the role of the control node (or server) is important, as it provides centralized management of the game's state and ensures synchronization between all players.


Let's consider a code example that demonstrates this approach. The GameStateManager class in this code serves as a global game state manager in the context of a networked game, using Unity's Mirror. It uses the singleton pattern to manage the only instance of GameStateManager:


public class GameStateManager : NetworkBehaviour
{
    private static GameStateManager instance;
    public static GameStateManager Instance { get { return instance; } }

    [Command]
    public void CmdMovePlayer(Vector3 position)
    {
        RpcMovePlayer(position);
    }

    [ClientRpc]
    private void RpcMovePlayer(Vector3 position)
    {
        // Update the player's position on all clients
        foreach (PlayerController player in players)
        {
            player.MoveTo(position);
        }
    }
}



The CmdMovePlayer method is used to send a command to the server to move the player to a new position. The server then broadcasts this information to all clients using RpcMovePlayer, which updates the position of each player on all client machines. This allows all clients to be aware of changes in the position of other players.


When a player presses the spacebar, a random new position on the field is selected, and then the CmdMovePlayer command is called to send this new position to the server:



public class PlayerController : NetworkBehaviour
{
    private void Update()
    {
        if (!isLocalPlayer) return;

        // Handle player input
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // Move the player to a new position
            Vector3 newPosition = new Vector3(Random.Range(-5f, 5f), 0f, Random.Range(-5f, 5f));
            CmdMovePlayer(newPosition);
        }
    }

    [Command]
    private void CmdMovePlayer(Vector3 position)
    {
        // Send the player's new position to the server
        GameStateManager.Instance.CmdMovePlayer(position);
    }

    public void MoveTo(Vector3 position)
    {
        // Move the player to the specified position
        transform.position = position;
    }
}


The CmdMovePlayer method then passes this information to the game state manager GameStateManager, which updates the player's position on the server. The MoveTo method is used for the actual movement of the player to the specified position on the client machine.


Input transmission

An alternative approach is to transmit user input. In this case, the player sends their command to all other participants, and each of them independently processes this input and updates the game state.


Continuing the example with player A, they send the command MoveTo(1, 2) to all players. After receiving confirmation from all players, Player A moves to the new position, and the character of Player A also moves to the new position on their screens.





Unlike the previous approach, this one does not require a control node that would centrally process all commands and update the game state. Instead, each player independently processes input and updates the game state:



public class InputManager : NetworkBehaviour
{
    public void SendInput(Vector3 moveDirection)
    {
        CmdSendInput(moveDirection);
    }

    [Command]
    private void CmdSendInput(Vector3 moveDirection)
    {
        RpcUpdateInput(moveDirection);
    }

    [ClientRpc]
    private void RpcUpdateInput(Vector3 moveDirection)
    {
        // Update the player's input on all clients
        foreach (PlayerController player in players)
        {
            player.UpdateInput(moveDirection);
        }
    }
}



This code sends player input to the server using the command CmdSendInput, and then the server broadcasts this input to all connected clients through RpcUpdateInput, updating the movement of each player on all client machines:




public class PlayerController : NetworkBehaviour
{
    private Vector3 inputDirection;

    private void Update()
    {
        if (!isLocalPlayer) return;

        // Handle player input
        float horizontalInput = Input.GetAxis("Horizontal");
        float verticalInput = Input.GetAxis("Vertical");

        inputDirection = new Vector3(horizontalInput, 0f, verticalInput).normalized;

        // Send the input to all other players
        InputManager.Instance.SendInput(inputDirection);
    }

    public void UpdateInput(Vector3 moveDirection)
    {
        // Update the player's input
        inputDirection = moveDirection;
    }
}



In each frame, horizontal and vertical input from the local player is read and this movement information is transmitted to all other players through InputManager.Instance.SendInput(inputDirection).


After that, the player's movement direction is updated based on incoming data from other clients using the UpdateInput method.


Choosing between State Transfer and Input Transfer

The choice between these two approaches largely depends on the requirements of the game. If a high degree of synchronization is required between all players and the possibility of cheating needs to be prevented, then the state transfer approach may be the preferred way. However, this usually demands more network traffic and it can also be more difficult to implement.


The approach of transferring input, on the other hand, may be easier to implement and it might require less network traffic, but this assumes that the game logic must be deterministic (i.e. it should give the same result with the same input). In some cases, this can be difficult to achieve.


Conclusion

We've talked about techniques for serializing and deserializing data for network transmission, the formats, and some available libraries to do this. We've also discussed which data (state or user input) should be transmitted over the network and in which situations.


In the next article, we will examine the main network topologies used in real-time multiplayer games!