Luau and Lua
Roblox's Luau started as a Lua 5.1 fork and has grown into its own language runtime. It has a custom bytecode VM, optional type annotations, native codegen, and a generational garbage collector. Understanding how these pieces interact helps you write faster game scripts.
The native codegen pipeline
When you enable --!native at the top of a script, Luau compiles hot functions to native machine code. This can give 2-5x speedups on computation-heavy code. Roblox API calls and event-heavy scripts often remain limited by other parts of the engine.
Native codegen works best on:
- Math-heavy loops (physics calculations, noise generation)
- Table iteration with numeric keys
- String manipulation in tight loops
Native codegen has less impact on:
- Roblox API calls (these cross the Lua/C++ boundary regardless)
- Code that's already bottlenecked on API rate limits
- Short functions that run once per event
--!native
--!strict
local function compute_damage(
base: number,
multiplier: number,
armor: number,
resistances: {number}
): number
local effective = base * multiplier
for _, res in resistances do
effective = effective * (1 - res)
end
return math.max(0, effective - armor)
endWith --!native and --!strict, Luau can type-check base, multiplier, and armor at compile time and generate tight native code for the arithmetic. Missing type annotations usually mean more runtime type checks.
Table allocation patterns
Tables are the most important data structure in Luau and a common source of performance problems. Every table allocation triggers GC tracking, and every resize copies data.
-- Table grows incrementally and triggers multiple resizes
local results = {}
for i = 1, 1000 do
results[i] = compute(i)
end
-- Pre-allocate the array part
local results = table.create(1000)
for i = 1, 1000 do
results[i] = compute(i)
endtable.create(n) pre-allocates n array slots. This avoids the geometric growth pattern where the table doubles in size when it runs out of space. For 1000 elements, the bad version triggers about 10 resizes (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024). Each resize allocates new memory and copies everything.
The hash part and array part
Luau tables have two internal storage areas: the array part (for integer keys 1..n) and the hash part (for everything else). Using sequential integer keys starting at 1 keeps data in the array part, which is a simple C array with O(1) indexed access.
-- Array part: fast sequential access
local positions = table.create(100)
for i = 1, 100 do
positions[i] = { x = 0, y = 0, z = 0 }
end
-- Hash part: slower, requires hashing
local positions = {}
for i = 1, 100 do
positions["entity_" .. i] = { x = 0, y = 0, z = 0 }
end| Access type | Storage | Lookup cost | Iteration |
|---|---|---|---|
t[1], t[2] | Array part | O(1), direct index | Sequential, fast |
t["key"] | Hash part | O(1) amortized, hash + probe | Unordered, slower |
t[1.5] | Hash part | O(1) amortized | Mixed with string keys |
Avoiding GC pressure
The generational GC in Luau divides objects into young and old generations. Young objects (recently created) get collected frequently. If an object survives enough collections, it gets promoted to the old generation which is collected less often.
The practical implication: creating temporary tables in hot loops generates GC pressure even if the tables are short-lived.
-- Allocates a new Vector3 every frame for every part
RunService.Heartbeat:Connect(function(dt)
for _, part in parts do
local vel = Vector3.new(part.vx * dt, part.vy * dt, part.vz * dt)
part.Position = part.Position + vel
end
end)
-- Use CFrame methods that avoid intermediate allocations
RunService.Heartbeat:Connect(function(dt)
for _, part in parts do
part.CFrame = part.CFrame + Vector3.new(
part.vx * dt, part.vy * dt, part.vz * dt
)
end
end)The best approach for truly hot paths is to avoid object allocations entirely and work with raw numbers:
-- No allocations, pure arithmetic
RunService.Heartbeat:Connect(function(dt)
for i, part in parts do
local cf = part.CFrame
local x, y, z = cf.X + vx[i] * dt, cf.Y + vy[i] * dt, cf.Z + vz[i] * dt
part.CFrame = CFrame.new(x, y, z) * cf.Rotation
end
end)Profiling in practice
Use the MicroProfiler (Ctrl+F6 in Studio) to see where your frame time goes. Most Roblox games spend time in:
- Roblox engine internals such as physics and rendering
- Network replication and remote event traffic
- Excessive Instance manipulation (creating/destroying parts)
- Unthrottled loops that do work every frame when they could do it less often
Script optimization matters when the profiler shows your scripts taking more than 2-3ms per frame. If the engine is eating 14ms of your 16ms budget, making your scripts 10x faster saves you 0.18ms. Focus on the bottleneck.
