Introduction
Network optimization is extremely crucial for games to have low-ping and quick join-times, and yet there’s close to no resources on the best practices for this! This post is made in hopes of shedding light on these issues, along with knowing when and how to optimize your game’s netcode.
General knowledge
Some general knowledge to know before exploring network optimization further:
-
One kilobyte (KB) is 1000 bytes
-
Remotes have a 9-byte overhead, e.g.
Remote:FireServer()
will take 9 bytes -
Every data type has a 1-byte overhead for their type, meaning no matter what you send there will be one extra byte attached
-
Strings take
#String + 2
bytes, e.g."1234"
takes 6 bytes (string size) + 1 byte (type overhead) to send through a remote -
Numbers are Float64s, which are 8 bytes (+ 1 byte type overhead) to send through a remote
-
Vector3s are made up of three Float32s, and take 12 bytes (+ 1 byte type overhead) to send through a remote
-
You can profile how much and which data remotes send with my plugin: Packet Profiler - accurately measure remote packet bandwidth!
With this knowledge, let’s head on to how you can optimize your network!
Send as little data as needed
When sending data about your items from the server to the client, the client doesn’t need to know everything about the item. You don’t need to send a large table containing the item’s damage data, but rather only send the item’s name and UUID if necessary.
Good:
{
"ItemName",
"ClassName"
}
Bad:
{
Name = "ItemName",
Class = "ClassName",
ItemHistory = {
["1658621547"] = {BoughtBy = "PlayerName", Price = 20}
},
DamageAmount = 20
}
The client doesn’t need ItemHistory
nor DamageAmount
- it only cares about the name and class of what it’s going to display on the UI!
Additionally, all VFX should be created on client. Luckily, this is pretty common practice already, but the server should never create visual effects on the server - it’s a lot more efficient to send the client a one-string message saying “SpawnParticle” than creating it on the server and having to replicate the particle, along with all of its properties, to all clients.
Converting dictionaries to arrays
Strings take up a lot of size. Just a simple string like ItemName
takes up 11 bytes! The best way to efficiently save space would be to convert dictionaries into arrays, removing any string-keys. Arrays don’t take any extra size for their indices, only values!
Of course, since dictionaries don’t have any specified order, you’ll have to hardcode it. Hardcoding is usually bad, but in the case of networking, it’s fine! There’s no one-size-fits-all solution to this, but my personal implementation is to have a PackFormat
module, which contains data about how tables should be packed and unpacked:
{
-- All dictionary fields
ItemName = true,
Class = true,
Serial = true,
-- Array order for dictionary fields
"ItemName", "Class", "Serial"
}
Packing data
Sometimes, you have to send some data, even if it ends up being a lot. If it’s not sent frequently (e.g. only upon game start), it’s fine! But if you send it frequently, you should try storing it more compactly. Let’s take an example:
You’re sending a string generated by HttpService:GenerateGUID(false)
(first argument is important!). The string is 36 characters long, making this one string take up 38 bytes total. But, as it turns out, the generated GUID is packed in hexadecimals!
You can use this knowledge to pack the data more efficiently, packing every 2 characters into one. This would slash the string size in half!
Warning: you cannot use this for DataStores, as the outputted string may not be in a valid utf8-format.
SerDes layer
The absolutely best way to optimize your game’s netcode while keeping the rest of your code readable and logical is to separate your remote logic from your game logic.
So, ideally you’d connect to all remotes in one module, handle serialization and deserilization (SerDes) in that module, and have scripts only receive the deserialized values, as if nothing was changed. Adjusting your codebase to this may be a bit tricky, but it is ultimately the best solution!
Other goodies
Some other cool things to know would be:
-
Instances are only 4 bytes through remotes, making them less expensive than even a 3-character string or a number.
-
Numbers are always 8 bytes. Doing
math.floor(Num)
won’t change anything! -
Color3’s pack their numbers as three Float32s, meaning they’re 12 bytes in total. If your colors will never exceed values between
0, 255
, you can instead just send three Int8s, which are 3 bytes in total, to store values between[0, 255]
instead. Of course, only way to do so would be to pack them into strings, so they’d be 5 bytes when including the string overhead. -
If you wish to send position data but only need integers and are able to discard decimal places, you can use Vector3int16s instead, which are twice as small.
-
CFrames have 24 special cases where they discard their rotational data, and only send a 1-byte long ID instead. This happens if the rotation is axis-aligned, i.e. if any of the rotational components are multiples of 90 - e.g.
CFrame.Angles(0, math.rad(90), 0)
,CFrame.Angles(0, math.rad(270), 0)
andCFrame.Angles(0, math.rad(-180), math.rad(-180))
only send their positions, along with an ID for their rotation.