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).