Multiplayer Data Compression and Bit Manipulation

Creating a multiplayer game in Unity is not a trivial task, but with the help of third-party solutions, such as PUN 2, it has made networking integration much easier.

Alternatively, if you require more control over the game's networking capabilities, you can write your own networking solution using Socket technology (ex. authoritative multiplayer, where the server only receives player input and then does its own calculations to ensure that all players behave in the same manner, thus reducing the incidence of hacking).

Regardless of whether you are writing your own networking or using an existing solution, you should be mindful of the topic that we will be discussing in this post, which is data compression.

Multiplayer Basics

In most multiplayer games, there is communication that occurs between players and the server, in the form of small batches of data (a sequence of bytes), which are sent back and forth at a specified rate.

In Unity (and C# specifically), the most common value types are int, float, bool, and string (also, you should avoid using string when sending frequently changing values, the most acceptable use for this type are chat messages or data that only contains text).

  • All of the types above are stored in a set number of bytes:

int = 4 bytes
float = 4 bytes
bool = 1 byte
string = (Number of bytes used to encode a single character, depending on encoding format) x (Number of characters)

Knowing the values, let's calculate the minimum amount of bytes that are needed to be sent for a standard multiplayer FPS (First-Person Shooter):

Player position: Vector3 (3 floats x 4) = 12 bytes
Player rotation: Quaternion (4 floats x 4) = 16 bytes
Player look target: Vector3 (3 floats x 4) = 12 bytes
Player firing: bool = 1 byte
Player in the air: bool = 1 byte
Player crouching: bool = 1 byte
Player running: bool = 1 byte

Total 44 bytes.

We'll be using extension methods to pack the data into an array of bytes, and vice versa:

  • Create a new script, name it SC_ByteMethods then paste the code below inside it:

SC_ByteMethods.cs

using System;
using System.Collections;
using System.Text;

public static class SC_ByteMethods
{
    //Convert value types to byte array
    public static byte[] toByteArray(this float value)
    {
        return BitConverter.GetBytes(value);
    }

    public static byte[] toByteArray(this int value)
    {
        return BitConverter.GetBytes(value);
    }

    public static byte toByte(this bool value)
    {
        return (byte)(value ? 1 : 0);
    }

    public static byte[] toByteArray(this string value)
    {
        return Encoding.UTF8.GetBytes(value);
    }

    //Convert byte array to value types
    public static float toFloat(this byte[] bytes, int startIndex)
    {
        return BitConverter.ToSingle(bytes, startIndex);
    }

    public static int toInt(this byte[] bytes, int startIndex)
    {
        return BitConverter.ToInt32(bytes, startIndex);
    }

    public static bool toBool(this byte[] bytes, int startIndex)
    {
        return bytes[startIndex] == 1;
    }

    public static string toString(this byte[] bytes, int startIndex, int length)
    {
        return Encoding.UTF8.GetString(bytes, startIndex, length);
    }
}

Example usage of the methods above:

  • Create a new script, name it SC_TestPackUnpack then paste the code below inside it:

SC_TestPackUnpack.cs

using System;
using UnityEngine;

public class SC_TestPackUnpack : MonoBehaviour
{
    //Example values
    public Transform lookTarget;
    public bool isFiring = false;
    public bool inTheAir = false;
    public bool isCrouching = false;
    public bool isRunning = false;

    //Data that can be sent over network
    byte[] packedData = new byte[44]; //12 + 16 + 12 + 1 + 1 + 1 + 1

    // Update is called once per frame
    void Update()
    {
        //Part 1: Example of writing Data
        //_____________________________________________________________________________
        //Insert player position bytes
        Buffer.BlockCopy(transform.position.x.toByteArray(), 0, packedData, 0, 4); //X
        Buffer.BlockCopy(transform.position.y.toByteArray(), 0, packedData, 4, 4); //Y
        Buffer.BlockCopy(transform.position.z.toByteArray(), 0, packedData, 8, 4); //Z
        //Insert player rotation bytes
        Buffer.BlockCopy(transform.rotation.x.toByteArray(), 0, packedData, 12, 4); //X
        Buffer.BlockCopy(transform.rotation.y.toByteArray(), 0, packedData, 16, 4); //Y
        Buffer.BlockCopy(transform.rotation.z.toByteArray(), 0, packedData, 20, 4); //Z
        Buffer.BlockCopy(transform.rotation.w.toByteArray(), 0, packedData, 24, 4); //W
        //Insert look position bytes
        Buffer.BlockCopy(lookTarget.position.x.toByteArray(), 0, packedData, 28, 4); //X
        Buffer.BlockCopy(lookTarget.position.y.toByteArray(), 0, packedData, 32, 4); //Y
        Buffer.BlockCopy(lookTarget.position.z.toByteArray(), 0, packedData, 36, 4); //Z
        //Insert bools
        packedData[40] = isFiring.toByte();
        packedData[41] = inTheAir.toByte();
        packedData[42] = isCrouching.toByte();
        packedData[43] = isRunning.toByte();
        //packedData ready to be sent...

        //Part 2: Example of reading received data
        //_____________________________________________________________________________
        Vector3 receivedPosition = new Vector3(packedData.toFloat(0), packedData.toFloat(4), packedData.toFloat(8));
        print("Received Position: " + receivedPosition);
        Quaternion receivedRotation = new Quaternion(packedData.toFloat(12), packedData.toFloat(16), packedData.toFloat(20), packedData.toFloat(24));
        print("Received Rotation: " + receivedRotation);
        Vector3 receivedLookPos = new Vector3(packedData.toFloat(28), packedData.toFloat(32), packedData.toFloat(36));
        print("Received Look Position: " + receivedLookPos);
        print("Is Firing: " + packedData.toBool(40));
        print("In The Air: " + packedData.toBool(41));
        print("Is Crouching: " + packedData.toBool(42));
        print("Is Running: " + packedData.toBool(43));
    }
}

The script above initializes the byte array with a length of 44 (which corresponds to the byte sum of all values that we want to send).

Each value is then converted into byte arrays, then applied into the packedData array using Buffer.BlockCopy.

Later the packedData is converted back to values using extension methods from SC_ByteMethods.cs.

Data Compression Techniques

Objectively, 44 bytes is not a lot of data, but if needed to be sent 10 - 20 times per second, the traffic starts to add up.

When it comes to networking, every byte counts.

So how to reduce the amount of data?

The answer is simple, by not sending the values that are not expected to change, and by stacking simple value types into a single byte.

Do Not Send Values That Are Not Expected To Change

In the example above we are adding the Quaternion of the rotation, which consists of 4 floats.

However, in the case of an FPS game, the player usually only rotates around the Y axis, knowing that, we can only add the rotation around Y, reducing rotation data from 16 bytes to just 4 bytes.

Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation

Stack Multiple Booleans Into A Single Byte

A byte is a sequence of 8 bits, each with a possible value of 0 and 1.

Coincidentally, the bool value can only be true or false. So, with a simple code, we can compress up to 8 bool values into a single byte.

Open SC_ByteMethods.cs then add the code below before the last closing brace '}'

    //Bit Manipulation
    public static byte ToByte(this bool[] bools)
    {
        byte[] boolsByte = new byte[1];
        if (bools.Length == 8)
        {
            BitArray a = new BitArray(bools);
            a.CopyTo(boolsByte, 0);
        }

        return boolsByte[0];
    }

    //Get value of Bit in the byte by the index
    public static bool GetBit(this byte b, int bitNumber)
    {
        //Check if specific bit of byte is 1 or 0
        return (b & (1 << bitNumber)) != 0;
    }

Updated SC_TestPackUnpack code:

SC_TestPackUnpack.cs

using System;
using UnityEngine;

public class SC_TestPackUnpack : MonoBehaviour
{
    //Example values
    public Transform lookTarget;
    public bool isFiring = false;
    public bool inTheAir = false;
    public bool isCrouching = false;
    public bool isRunning = false;

    //Data that can be sent over network
    byte[] packedData = new byte[29]; //12 + 4 + 12 + 1

    // Update is called once per frame
    void Update()
    {
        //Part 1: Example of writing Data
        //_____________________________________________________________________________
        //Insert player position bytes
        Buffer.BlockCopy(transform.position.x.toByteArray(), 0, packedData, 0, 4); //X
        Buffer.BlockCopy(transform.position.y.toByteArray(), 0, packedData, 4, 4); //Y
        Buffer.BlockCopy(transform.position.z.toByteArray(), 0, packedData, 8, 4); //Z
        //Insert player rotation bytes
        Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
        //Insert look position bytes
        Buffer.BlockCopy(lookTarget.position.x.toByteArray(), 0, packedData, 16, 4); //X
        Buffer.BlockCopy(lookTarget.position.y.toByteArray(), 0, packedData, 20, 4); //Y
        Buffer.BlockCopy(lookTarget.position.z.toByteArray(), 0, packedData, 24, 4); //Z
        //Insert bools (Compact)
        bool[] bools = new bool[8];
        bools[0] = isFiring;
        bools[1] = inTheAir;
        bools[2] = isCrouching;
        bools[3] = isRunning;
        packedData[28] = bools.ToByte();
        //packedData ready to be sent...

        //Part 2: Example of reading received data
        //_____________________________________________________________________________
        Vector3 receivedPosition = new Vector3(packedData.toFloat(0), packedData.toFloat(4), packedData.toFloat(8));
        print("Received Position: " + receivedPosition);
        float receivedRotationY = packedData.toFloat(12);
        print("Received Rotation Y: " + receivedRotationY);
        Vector3 receivedLookPos = new Vector3(packedData.toFloat(16), packedData.toFloat(20), packedData.toFloat(24));
        print("Received Look Position: " + receivedLookPos);
        print("Is Firing: " + packedData[28].GetBit(0));
        print("In The Air: " + packedData[28].GetBit(1));
        print("Is Crouching: " + packedData[28].GetBit(2));
        print("Is Running: " + packedData[28].GetBit(3));
    }
}

With the methods above, we have reduced the packedData length from 44 to 29 bytes (34% reduction).

Links
Unity 6