Table of Contents
Why Script Optimization Matters
Script optimization is about making your Lua code run faster and more efficiently so your Roblox game feels smooth and responsive. In Roblox, every script you write competes for CPU time with everything else that happens in the game. Poorly written scripts can cause stutters, delayed input, or low frame rates, even if your models and assets are simple.
In this chapter you focus specifically on how to write and structure scripts so they do not waste work. You will see patterns that are slow, and learn how to rewrite them into faster and cleaner versions. The goal is not to turn you into a low level performance engineer. The goal is to give you habits that prevent big performance issues before they appear.
Efficient scripts are usually:
- Doing less work.
- Doing work less often.
- Avoiding unnecessary allocations and lookups.
- Using Roblox services and events instead of constant looping.
Avoiding Expensive Infinite Loops
The most common performance problem in beginner games comes from while true do loops that run forever and do too much work every frame.
A loop that runs constantly with no pause will try to use as much CPU as it can. In Roblox you should almost never write a loop like this:
while true do
-- bad, no wait, runs as fast as possible
print("Checking something")
endThis loop never yields. It will compete with everything else in the game, and your frame rate will drop.
If you must use a loop, always slow it down with task.wait or wait so it runs at a reasonable rate. For example, if you need to check something only twice a second, write:
while true do
-- Some periodic work
print("Checking something")
task.wait(0.5)
end
Better yet, ask yourself if you need a loop at all. Many patterns that beginners implement with loops are better handled with events. For example, instead of looping forever and checking if the player touches something, connect to a Touched event. Instead of polling player stats every frame, update UI only when the stats actually change.
Rule: Do not use while true do or RunService.Heartbeat:Connect for constant checking if an event exists that tells you exactly when something changes.
Using Events Instead of Polling
Polling is when you repeatedly check the same value to see if it changed. Event driven code only runs when something actually changes.
Consider this polling style:
while true do
if part.Touched then
-- handle touch (this is conceptual, Touched is an event, not a property)
end
task.wait()
endThis not only does not work logically, it also wastes time. Roblox already gives you events for most game interactions. For example:
local part = workspace.SomePart
part.Touched:Connect(function(hit)
-- handle touch here
end)In this style, the code sleeps until a player actually touches the part, then runs the handler. No extra CPU is used when nothing happens.
The same idea applies to many other areas. Use Changed events on instances when you want to respond to property changes. Use ChildAdded and ChildRemoved for folder contents. Use Players.PlayerAdded for new players instead of checking the list of players in a loop.
This structure makes your code more efficient and easier to understand, because behavior is clearly tied to game events.
Minimizing Work per Frame
Some code must run every frame, for example if you are updating smooth animations or doing continuous movement. Roblox gives you frame based events like RunService.RenderStepped, RunService.Heartbeat, and RunService.Stepped for this.
However, attaching heavy logic to these events is expensive, because these functions are called up to 60 times per second. For example, this is risky:
local RunService = game:GetService("RunService")
RunService.RenderStepped:Connect(function(deltaTime)
for _, player in pairs(game.Players:GetPlayers()) do
-- Heavy calculations here, every frame, for every player
end
end)If you must use a per frame event, keep the work inside as small as possible. Move heavy calculations out of the per frame code. Pre compute data that does not change often. Use simple arithmetic and avoid repeated searches inside the loop.
A simple way to reduce work is to run heavy logic less frequently. For example, if you only need to update something 10 times a second, you can accumulate time:
local RunService = game:GetService("RunService")
local accumulator = 0
local interval = 0.1
RunService.Heartbeat:Connect(function(deltaTime)
accumulator = accumulator + deltaTime
if accumulator >= interval then
accumulator = accumulator - interval
-- Do work only every 0.1 seconds
end
end)By controlling how often you perform heavy tasks, you keep frame time consistent and avoid spikes that cause stutters.
Reducing Repeated Lookups and Calls
Many Roblox API calls perform searches in the game hierarchy. For example, workspace:FindFirstChild("Something") walks through children to find a match. Doing this repeatedly in a loop is more expensive than doing it once and storing the result.
A common slow pattern looks like this:
while true do
local part = workspace:FindFirstChild("Coin")
if part then
-- Do something with part
end
task.wait(0.1)
end
If the Coin does not move to a completely different location in the hierarchy, there is no reason to search every time. Instead, find it once and reuse the reference:
local coin = workspace:WaitForChild("Coin")
while true do
-- Use `coin` directly
task.wait(0.1)
end
The same idea applies inside loops. Avoid calling game.Players:GetPlayers() inside a loop that already iterates over players. Cache services and objects in local variables at the top of your scripts. Local variables are much faster to access than repeated global lookups like game.Players.
A good pattern is:
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local myFolder = Workspace:WaitForChild("MyFolder")
Then use Players and myFolder instead of game:GetService("Players") and workspace.MyFolder everywhere.
Rule: Store services and frequently used objects in local variables and reuse them instead of calling GetService or FindFirstChild many times.
Choosing the Right Data Structures
When you store and process data in Lua, your choice of structure has a big impact on performance. The main structure in Lua is the table. Tables can act like lists (arrays) or dictionaries (maps from keys to values).
If you often need to check if something exists, a dictionary is faster than scanning a list. For example, imagine you track which players have a power up.
A slow pattern is:
local activePlayers = {}
local function hasPowerUp(player)
for _, p in ipairs(activePlayers) do
if p == player then
return true
end
end
return false
endThis loop gets slower as more players are added. A more efficient version uses a dictionary where the keys are players:
local activePlayers = {}
local function givePowerUp(player)
activePlayers[player] = true
end
local function removePowerUp(player)
activePlayers[player] = nil
end
local function hasPowerUp(player)
return activePlayers[player] == true
endIn this version, checking if a player has the power up is a simple dictionary lookup, which is much faster and does not grow slower as the list grows.
Use list style tables when you care about order or when you iterate through every item. Use dictionary style tables when you look up elements by key, such as by player, ID, or name.
Avoiding Unnecessary Object Creation
Creating lots of Instances or tables during gameplay can cause performance problems, especially if you do this every frame or every time something minor happens. Each created object must be allocated and later collected, which takes time and can cause small pauses.
For example, this pattern inside a fast loop is problematic:
local RunService = game:GetService("RunService")
RunService.Heartbeat:Connect(function()
local tempTable = {}
for i = 1, 100 do
table.insert(tempTable, i)
end
end)Every frame this creates a new table and 100 new entries. Instead of creating short lived tables repeatedly, consider reusing tables. Create them once and clear them when needed.
Similarly, do not create and destroy Instances too frequently inside performance sensitive code. If you need similar effects repeatedly, consider reusing an existing object. For example, if you show damage numbers above enemies, you can pool a set of text labels and reuse them instead of creating new ones and destroying them every hit.
A simple reuse pattern looks like this:
local pool = {}
local function getObject()
if #pool > 0 then
local obj = table.remove(pool)
obj.Visible = true
return obj
else
-- create a new object if pool is empty
local obj = Instance.new("BillboardGui")
return obj
end
end
local function releaseObject(obj)
obj.Visible = false
table.insert(pool, obj)
endThis idea, called object pooling, reduces the cost of constant creation and deletion.
Controlling Script Activity with Conditions
Often you do not need a system to run all the time. You can turn logic on when needed and off when it is not. This avoids wasted CPU cycles.
For example, imagine an enemy AI script that runs logic even when no players are near. A better design is to activate complex behavior only when players are in range.
One approach is to use boolean flags to guard your logic:
local active = false
local function setActive(value)
active = value
end
while true do
if active then
-- Only run AI logic when active is true
end
task.wait(0.1)
endEven better, move logic into functions that are connected and disconnected from events as needed. For example, connect pathfinding updates only when the enemy is actually chasing something, and disconnect the event when the chase ends.
You can apply the same approach to UI systems, environmental effects, and many other features. If a script does not need to run, design it so it can sleep completely.
Optimizing Expensive Calculations
Some operations are inherently more expensive than others. For example, pathfinding, distance checks over many objects, and frequent raycasts can quickly become bottlenecks.
One optimization is to reduce how often you perform these calculations. If you do not need perfect real time updates, calculate less often or only on changes. Another is to limit the number of objects you check.
For example, a naive distance check pattern scans all parts in a folder every frame:
local RunService = game:GetService("RunService")
local Folder = workspace:WaitForChild("Targets")
local character = script.Parent
RunService.Heartbeat:Connect(function()
for _, target in ipairs(Folder:GetChildren()) do
local distance = (target.Position - character.Position).Magnitude
if distance < 20 then
-- do something
end
end
end)Instead, you can limit checks to every 0.2 or 0.5 seconds. You can also stop once you find a valid target if you do not need all of them. For example:
local RunService = game:GetService("RunService")
local Folder = workspace:WaitForChild("Targets")
local character = script.Parent
local accumulator = 0
local interval = 0.2
RunService.Heartbeat:Connect(function(deltaTime)
accumulator = accumulator + deltaTime
if accumulator < interval then
return
end
accumulator = 0
for _, target in ipairs(Folder:GetChildren()) do
local distance = (target.Position - character.Position).Magnitude
if distance < 20 then
-- handle first target found and break
break
end
end
end)This keeps the logic responsive while significantly reducing the total amount of computation.
Rule: Expensive operations such as pathfinding, raycasting, and large distance loops should be done as rarely as possible and only on the subset of objects that matter.
Structuring Scripts for Performance and Clarity
Optimization is not only about micro changes. The overall structure of your code affects performance too. Clear separation between server and client scripts allows you to put heavy local calculations on the client and leave the server to handle only what is necessary. Organizing code into small, focused modules makes it easier to find and fix bottlenecks.
You should aim for scripts that do one kind of job each, such as a script only for enemy behavior, another only for coin collection, and another only for UI. When a performance problem appears, you can identify which script is responsible instead of searching through a huge file that handles everything.
Keep logic that runs often, such as frame based updates, as small and contained as possible. Move slower changing logic into different functions that are called less often. This separation makes optimizations much easier.
Measuring and Iterating on Performance
Good optimization is based on measurement. Guessing which part of your script is slow is often wrong. Instead, you can add simple timing checks to see how long a piece of code takes.
You can measure time using tick() or os.clock():
local startTime = os.clock()
-- Code you want to test
for i = 1, 100000 do
local x = i * 2
end
local endTime = os.clock()
print("Time taken:", endTime - startTime)Use this type of timing in development to compare two different approaches. If one version is clearly faster and still easy to read, choose that version.
When optimizing, focus first on code that runs very often or processes many objects. There is no need to micro optimize a function that runs once when the game loads. Instead, look at loops that run every frame, network related code, and AI or physics logic.
As you build more complex games, you will combine profiling, output messages, and larger scale testing to refine performance. Efficient script design early on will make it much easier to keep your game smooth as it grows.