The definitive tutorial to game optimization: everything you need to know

This post was supposed to be longer, but Roblox characters limit did not allow me to. Here is the rest of the sections:

11. Coding performance tips

Click here coders!

This guide would not be complete without sections dedicated to coding. I obviously cant include everything as coding optimizations really depend on what you are trying to make, but I will share general optimization tricks and that should lead you into being able to think solutions to more complex coding performance problems.

The main idea is that less is more. If you have performance problems related to a script, you need to find a way to do the same / something similar with less instructions.

Always remember the golden rules of code optimization:

  • Rule #1: Don’t do it.
  • Rule #2: Don’t do it yet.

11.1 Pre-Allocating tables

Luau allows you to pre-allocate space for a table with the method table.create.

local MyTable = table.create(100)

This allows Luau to save the time it would have taken re-allocating space in the table. You have to be careful with the size though, as pre-allocating space requires memory. You can not pre-allocate SharedTables, in case you were wondering about using this with Parallel Luau.

This allows for better performance for when you have to assign values to the table. There are two ways to assign values:

  • table.insert(table, value): faster when the table size is not known
  • MyTable[index] = value: faster when the table size is pre allocated with table.create. Can be faster when the table is being filled sequentially (1, 2, 3, 4…)

I made tests to check this, here is the code:

task.wait(5) --wait for game to load up so other things do not affect tihs

local TableNotPreAllocated = {}
local startTime = os.clock()
for i = 0, 20000, 1 do
	TableNotPreAllocated[i] = "Testing"
end

print(`Not pre allocating and then assigning with index took {os.clock() - startTime} seconds`)

----------------------------------------------------------------------------------------------
local Table = {}
local startTime = os.clock()

for i = 0, 20000, 1 do
	table.insert(Table, "Testing")
end

print(`Not pre allocating and then using table.insert took {os.clock() - startTime} seconds`)

-----------------------------------------------------------------------------------------------
local TablePreAllocated = table.create(20000)
local startTime = os.clock()

for i = 0, 20000, 1 do
	TablePreAllocated[i] = "Testing"
end

print(`Pre allocating and then assigning took {os.clock() - startTime} seconds`)

-----------------------------------------------------------------------------------------------
local TablePreAllocated = table.create(20000)
local startTime = os.clock()

for i = 0, 20000, 1 do
	table.insert(TablePreAllocated, "Testing")
end

print(`Pre allocating and then using table.insert took {os.clock() - startTime} seconds`)

In the majority of cases, the last 2 were the fastest. Pre allocate your tables when you can!

11.2 Native codegen

Roblox allows native code generation in the server scripts, allowing the code to be translated into instructions that the CPU can execute directly instead of the bytecodes that Lua operates on. You can enable it in your scripts by using the “–native” flag on the first line of the script or by using @native on top of a function, example:

@native
local function f(x)
   return (x + 1)
end

Native codegen offers the most benefit in scripts that perform a lot of computation. If you have a lot of math operations and especially buffer library usage in your script, enabling native code generation is recommended.

It is important to remember that only the functions are compiled natively. It is also recommended to measure the time it takes to your script to perform X function with native code gen vs without.

It is easy to think that you should enable native code generation in every server-side script of your game. Homewer, you have to consider the following drawbacks:

  • Code compilation time is required which can increase the startup time of servers.

  • Extra memory is occupied to store natively compiled code.

  • There’s a limit on the total allowed amount of natively compiled code in an experience.

If you decide to use this, remember to use type notation. From the Roblox docs:

Native code generation attempts to infer the most likely type for a given variable in order to optimize code paths. For example, it’s assumed that a + b is performed on numbers, or that a table is accessed in t.X. Given operator overloading, however, a and b may be tables or Vector3 types, or t may be a Roblox datatype.

While native code generation will support any type, mispredictions may trigger unnecessary checks, resulting in slower code execution.

To solve some common issues, Luau type annotations on function arguments are checked, but it’s especially recommended to annotate Vector3 arguments:


--!native

-- "v" is assumed to be a table; function performs slower due to table checks
local function sumComponentsSlow(v)
	return v.X + v.Y + v.Z
end

-- "v" is declared to be a Vector3; code specialized for vectors is generated
local function sumComponentsFast(v: Vector3)
	return v.X + v.Y + v.Z
end

11.3 Parallel Luau

I will take the information from my General guide on making Roblox RTS games: What you should know before starting - Resources / Community Resources - Developer Forum | Roblox guide.

Parallel luau lets you divide work between multiple threads for the CPU, reducing the processing time.

I will not lie, it is complicated and time-consuming to implement it correctly and you will probably have headaches, and Roblox parallel processing is also kinda bad, but the rewards are usually worth it and provide a faster and smoother experience, but…

Be careful about sending too much data to the threads!

Sending too much data to different threads has a huge impact in performance, making it even worse than without parallel luau. Actor:SendMessage() taking too much time - #15 by WoloPoints

Use buffers to compress your data and optimize it to the point where parallel luau will make a good / positive difference. You can also try using bit32, but be aware, its a pain to use if you have no experience.

Roblox shows a good example of the power of Parallel Luau in their documentation Parallel Luau | Documentation - Roblox Creator Hub.

11.4 The power of representing the visuals on the client

The idea of representing visual things in the client and managing data in the server is not new. For example, when you make a RTS, you tipically do not want to spend resources into saving and managing 3D models, particles, images and other things data in the server because you want the server to manage the units. So, what developers usually do in this case is to handle pure numbers and text in the server, and tell the client to represent it. In a RTS game, this would be the server telling the client to move an unit model to a new position or to peform an attack animation.

Lets imagine that I have 2000 parts that I need to move for players only. Think about it: what is more demanding?

  • Server moves the parts in its side first
  • Send the new positions information to the client
  • The client has to receive the information
  • The client has to process the information received
  • After that, the client also has to move the parts in its side.

Or this

  • Server sends a signal through a remote event
  • The client moves the parts in its side

There is almost no network resources usage, because it is just 1 signal, therefore all that time of the client managing the data sent is saved. Lets pust a visual example:

This scene, moving 2000 meshparts, requires around 2500 Kb/s of data being sent to the clients (Recev), this works moving the parts on the server.

This scene with again 2000 meshparts requires almost no Kb/s, since its jut 1 signal sent to order the client to move the parts.,

This obviously is not applicable in every case and it can be adapted to different uses. Instead of having meshparts in the server, you could try creating them in the client, only storing numbers data in the server and then send the numbers data to move the parts every time they change.

This doesnt only work for moving parts. You can use this for anything too expensive network-wise to be handled on the server, like a lot of particles.

NetworkPerformance_ClientVisualRepresentation_Test.rbxl (118.0 KB)

11.5 Do not spam print() or warn()

The idea of this section is simple; printing text in console is slow.

image

Printing stuff in console also requires memory to be saved. You should avoid using print() and warn() for the published versions of your game, reserve them only for Studio. Yes, the example shown previously may seem small, but consider that my PC CPU is powereful. Lower end devices will have trouble with print() spam.

11.6 Avoid parenting objects under Workspace and then changing properties server-side.

Think about it; when you create an instance in workspace, it needs to be replicated to the clients with everything it has. If you change a property of it after it is already replicated, the server will have to replicate that change to the clients. What is faster?

  • Create an instance
  • Parent it under workspace
  • Replicate that information to the clients
  • Change a property
  • Replicate the change to the clients

Or

-Create an instance

  • Parent it under workspace
  • Replicate the information to the clients

This mainly counts as network optimization. Also, at least for me, changing properties before setting the .Parent one seems more pretty to me!

11.7 Pooling

“Pooling” in this context just means “re-use instead of destroying”. It is more performant to use an already existing instance in the game than it is to create it from scratch. This has the drawback of using more memory, since you need to keep the data of the object somewhere. Example taken from ericmasterofwars:

local Bullet = script.Bullet
local Bullets = table.create(300) :: {Part}

local function spawnBullet()
   local bullet = table.remove(Bullets)
   if not bullet then
        bullet = Bullet:Clone()
   end
  bullet.Parent = workspace
   --Do some stuff bla bla bla
 
   bullet.Parent = nil -- Remove bullet from existence for now
   table.insert(Bullets, bullet) --Save it for further use later
end

11.8 Be careful with anti-cheats

How many things does an anti needs to do? Client-side anti cheats are easy to bypass therefore you have to put them server-side, but Roblox doesnt dedicate that many resources to your game. An anti cheat needs tob e checking what are the players doing all the time. It is not performant.

Consider if your game really needs an anti-cheat before even thinking about implementing one

11.9 Do not use getfenv / setfenv

Using these globals disable code optimization, as it is said in Deprecate getfenv/setfenv | Luau RFCs.

From that page:

getfenv and setfenv are problematic for a host of reasons:

  • They allow uncontrolled mutation of global environment, which results in deoptimization; various important performance features like builtin calls or imports are disabled when these functions are used.

11.10 FindFirstChild is slower

Using :FindFirstChild() in your code is 20% longer than doing something like workplace.object and 8 times longer than storing a reference and using that.. From the Roblox documentation Instance | Documentation - Roblox Creator Hub:

FindFirstChild() takes about 20% longer than using the dot operator and almost 8 times longer than simply storing a reference to an object. Therefore, you should avoid calling it in performance-dependent code such as in tight loops or functions connected to RunService.Heartbeat and RunService.PreRender. Instead, store the result in a variable, or consider using ChildAdded or WaitForChild() to detect when a child of a given name becomes available.

You should not replace :FindFirstChild() with dot operator in every part of your code. Reserve it to sections that really need optimization.

11.11 Are humanoids really the devil?

Humanoids have a ton functions and features that require memory and resources to run; though the reason they can lag your game can be what you make them to do, not humanoids themselves.

Pathfinding is very expensive. If you have over 100 NPCs following the player and they all use pathfinding, it will lag. If each of the NPCs have heavy calculations in a script in them, it will lag.

Humanoids are not necessary th edevil, but you have to be conscious about how to use them. There are simple ways to optimize them, though. These are:

  • Play NPC animations on the client: playing them on the server requires network resources in the form of replicating from server to client.

  • Use performance-friendly alternatives to Humanoids: if you have static NPCs, they do not need humanoids. Use AnimationController if you need animation

  • Disable unused humanoid states - Use Humanoid:SetStateEnabled() to only enable necessary states for each humanoid.

  • Pool NPC models with frequent respawning - Use the pooling knowledge we talked about to avoid the time of creating more NPCs

  • Only spawn NPCs when users are nearby - Don’t spawn NPCs when users aren’t in range, and cull them when users leave their range.

  • Avoid making changes to the avatar hierarchy after it is instantiated - Certain modifications to an avatar hierarchy have significant performance implications. Some optimizations are available:

All of these are taken from the Roblox documentation. An etra tip from me is that you need to be careful with your NPCs 3D modelers. 1 hat with a lot of triangles may not seem much, but when you multiply it by 12 it starts to add up!

12. Terrain: hollow or filled?

A great question

A big debate matter on optimization is the question: should I thin out my terrain or should I keep it filled? There is one answer from Roblox, but it is very old. Optimizing Terrain by thinning it out - Help and Feedback / Scripting Support - Developer Forum | Roblox

The staff member that said this does not specify if it is more more expensive CPU-wise or memory-wise, but if I had to guess, It would be CPU-wise.

This may be because the engine handles filled terrain bottom / sides easier, as they tend to be very flat. If you hollow the terrain becomes it more complex. Favoring CPU performance in this scenario would be better, because devices usually have a ton of memory; CPU usually doesnt have that much power.

But still, thinning terrain should improve memory usage. Consider it and think; should I prioritize CPU or memory usage for this game? It all depends on what you are working on.

13. Performance issues of the past

Roblox limit didnt allow this!

In this section I will talk about things that were harmful for performance back in the past, but are not anymore.

13.1 Rendering things behind other objects

Back in the past, Roblox used to render things hidden by parts / meshes / terrain, meaning that even if you could not see it, it would still be consuming resources while it was on front on the camera.

  • Taken from MrChickenRocket post, 2024

Uh oh. That’s the whole map - another ~30 zones are being rendered behind here!

That is no longer the case. Roblox released Occlussion Culling a while ago, making this no longer an inconvenience. Read more about it here:

Occlusion Culling Now Live in Roblox Client - Updates / Announcements - Developer Forum | Roblox

Shadows, lights, VFX are not occluded.

13.2 Storing methods as variables

Back in the past, storing methods as local variables used to be a nice way to improve performance, so is said in the Lua Performance Tips PDF, made by the creator of Lua, Roberto Ierusalimschy.

'If you need to squeeze performance out of your program, there are several
places where you can use locals besides the obvious ones. For instance, if you
call a function within a long loop, you can assign the function to a local variable.
For instance, the code'

for i = 1, 1000000 do
      local x = math.sin(i)
end

runs 30% slower than this one:

local sin = math.sin
for i = 1, 1000000 do
      local x = sin(i)
end

This was like this in Lua because calling globals (math., for example) was expensive.

In Luau, though it is based in Lua, the improvement on doing this is not longer as massive as it used to be. This is because of the implementation of Fastcalls. The easy way to explain this is that the Luau compiler does some voodo magic to make it so its more performant, as it is said so in the L page, Fast method calls section.

Caching methods in local can still be beneficial to performance, but the difference is extremely small and your code readability will be worsened considerably.

13.3 Using the --optimize flag

There is the belief that writing “–optimize 2” will improve your game scripts performance. Though the --optimize flag does exist, writing --“optimize 2” will do nothing to your game when it is played in the client. From the Roblox docs, Luau comments | Documentation - Roblox Creator Hub:

The --!optimize directive controls the optimization level of the Luau compiler for the script:

  • 0 disables optimizations.

  • 1 enables basic optimizations (default in Studio testing).

  • 2 enables further optimizations (default in live experiences).

It can be easy to be tricked because Roblox Studio only uses level 1 by default. But writing “–optimize 2” will not make any difference once you are playing it in the Roblox client.

13.4 For loops, pairs, ipairs or general iteration? (coding)

This is a question that some developers have asked. What the official Luau ( How we make Luau fast | Luau) says is:

Luau implements a fully generic iteration protocol; however, for iteration through tables in addition to generalized iteration (for .. in t) it recognizes three common idioms (for .. in ipairs(t), for .. in pairs(t) and for .. in next, t) and emits specialized bytecode that is carefully optimized using custom internal iterators.

As a result, iteration through tables typically doesn’t result in function calls for every iteration; the performance of iteration using generalized iteration, pairs and ipairs is comparable, so generalized iteration (without the use of pairs/ipairs) is recommended unless the code needs to be compatible with vanilla Lua or the specific semantics of ipairs (which stops at the first nil element) is required. Additionally, using generalized iteration avoids calling pairs when the loop starts which can be noticeable when the table is very short.

Iterating through array-like tables using for i=1,#t tends to be slightly slower because of extra cost incurred when reading elements from the table.

This basically means that you should not worry about performance diferences. Even if you get a litle bit more of performance; it is not enough to lose your time checking on it.

Homewer, as the documentation says, avoid doing for loops like for i=1,#t. It makes your performance worse

15 Likes