Network Optimization best practices - how to keep your game's ping low!

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) and CFrame.Angles(0, math.rad(-180), math.rad(-180)) only send their positions, along with an ID for their rotation.

143 Likes

Thanks for the great tutorial, learned many new insights. Bookmarked for later reference.

1 Like

This is something I had no idea was real, albeit it makes since considering that you’re sending the reference of the instance, not the instance itself.

Good read!

4 Likes

Yeah, I was seriously surprised at this myself! I was sending 4-char string identifiers before thinking it was as good as you could get, but sending the ModuleScript which the client needed to execute ended up being even less expensive.
The only way to make this even more efficient is by packing numbers between 0-255 inside a string through string.char(num), but even that would only be 1 byte less, and restrict you to 256 IDs max. Meanwhile, instances are sent as unsigned Int32s, meaning they can store up to 4294967296 unique IDs!

3 Likes

Just to confirm: remotes really only support ASCII characters for strings with Unicode being represented using multiple bytes per character, is this correct? Does this make base93 the most optimal and go-to compression method?

Also, I never knew about the overheads for value types, this is quite useful to know!

1 Like

Correct - all characters 0-255 are one character, and anything else, such as emojis, are encoded as several characters.

That’s up for you to decide. I’m not too overly familiar in this field :slightly_smiling_face:

1 Like

How effective is packing numbers with string.pack at reducing bandwidth usage?

It depends how many values you pack; a string has a byte size overhead of 2 bytes, so despite Lua numbers being 8 bytes, packing an int32 would take up 6 bytes - a difference in only 2 bytes, rather than what would’ve been 4.
The best approach would be to pack as many values as possible into one string - i.e., packing 10 numbers into 1 string is better than packing 10 numbers into 10 strings.
The MsgPack module is a great tool to do this!

Don’t forget you can use string.pack and string.unpack if you really want to squeeze them bytes down.

1 Like

Yeah, I didn’t mention it in the post as to keep it more theoretical; people should apply the knowledge learnt from this on their own accord. Usecases vary from person to person, and I wouldn’t want to give sub-optimal advice :slightly_smiling_face:

1 Like

Isn’t 1 KB equal to 1024 bytes?

1 Like

A kibibyte (short for KiB) is 2^10, otherwise 1024, bytes. A kilobyte (formally denoted as kB, but usually as just KB by e.g. Roblox) is exactly 1000 bytes. A kibibyte being close to 1000 bytes is just a mathematical coincidence.

4 Likes

Remotes support binary strings (characters 0-255.) I’ve been using this for years. You can use string.pack to achieve compact results efficiently. You mainly need to be careful with DataStoreService, MessagingService, and probably MemoryStoreService (I haven’t tested it yet.) They don’t support binary strings, but perhaps someday they will.

Encoding performance is also important to consider. Compact remote calls aren’t worth it if the server spends milliseconds formatting the data.

1 Like

That’s what I was using in the post you replied to. Since strings have a 2-byte size constant overhead, you’re only left with 2 characters before reaching a 4 bytes size - which can at max represent an unsigned 2^16 number. Referents don’t require any constant overhead, and can instead represent up to an unsigned 2^32 number.

If you use strings for the data, you can include an id alongside the data itself without needing an extra value. For the most compact remote calls you can queue them until the end of the frame and table.concat them into one big string, because at that point those 2-4 + 9-ish bytes are negligible. Then you can optimize it in any way you want.

3 Likes

Yeah, when sending multiple packed strings I do the same practice, but when sending one-off events like using a spell, I’ve found it to be a lot more efficient to send the script in question directly rather than a packed identifier for the spell.

That’s weird I was taught that a kilobyte is 1024 bytes.

How would I go about compressing the string with this?

1 Like