Luau "Out of Local Registers": Why It Only Breaks in Studio
A Luau script one local past the 200 limit compiled fine for us but broke in Roblox Studio. Why debug level 2 changes the count, how to reproduce it, and fixes.
We added one line to the client HUD script of Shopping Cart Mayhem: a new top-level local. Our checks passed. The type checker was happy, and the script compiled with the command-line compiler. Then we opened the place in Roblox Studio and the whole HUD was gone. The output window had one line:
CompileError: Out of local registers when trying to allocate K200: exceeded limit 200
This post explains why a script can compile on your machine and still hit this error in Studio, how to reproduce it outside Studio, and the fixes we now use.
The limit: 200 locals per function
Luau runs each function in a set of registers. Every live local variable takes one. The compiler caps this at 200 local registers per function, and the error above is what you get past that cap.
Two things make the limit easy to hit in Roblox code:
- A script or module is itself a function. All top-level locals in a file count against one function's budget: every
game:GetService, everyrequire, every remote, every UI reference, every constant. - Big client scripts grow at the top level. A HUD script collects one
localper label, frame and button, and nobody counts them.
Our HUD script had been sitting just under the cap for a while. The new line was the one that crossed it.
Why it compiled locally
Here is the part that cost us time. The same file compiled cleanly with luau-compile at its default settings. It failed in Studio.
The difference is constant locals. A constant local is one assigned a constant value once and never reassigned:
local MAX_SPEED = 24
local CATCH_WINDUP = 0.8
local BRAND = "MEGA MART"
At the default compile settings (optimization level 1, debug level 1), the compiler folds these. Every use of MAX_SPEED becomes the number 24, and the local takes no register. A file with 250 constant locals compiles fine, because most of them never reach a register.
At debug level 2, the compiler keeps those locals in registers so a debugger can show them. Now every constant counts toward the 200, and the same file fails.
From what we saw, Studio compiles scripts with debug level 2. That matches every case we have hit: a file that compiles at the default level and fails at debug level 2 also fails in Studio. We have not found an official statement of Studio's exact compile settings, so treat this as observed behavior.
Reproducing it outside Studio
We generated test files and compiled them with luau-compile at different settings. The results:
| File | Default (-O1 -g1) | -g2 | -O0 |
|---|---|---|---|
| 199 constant locals | ok | ok | ok |
| 201 constant locals | ok | fail | fail |
| 250 constant locals | ok | fail | fail |
201 non-constant locals (local V = tick()) | fail | fail | fail |
Two things to read from this table:
- Non-constant locals fail at every level. If your locals hold function calls, the limit is the limit, and you will see the error everywhere.
- Constant locals only fail when folding is off. Debug level 2 turns it off, and so does optimization level 0.
To check a file the way Studio sees it:
luau-compile --binary -g2 src/client/init.client.luau > /dev/null
One warning from our own testing: with the luau-compile build we use, the order of flags changed the result. -g2 alone and -g2 -O1 both failed on a bad file, while -O1 -g2 passed the same file. We did not dig into why. The lesson is to prove your check fails on a known-bad file before you trust it to pass a good one.
If you use Lune, its @lune/luau library exposes the compiler with explicit options, which avoids flag parsing entirely:
local luau = require("@lune/luau")
local ok, err = pcall(luau.compile, source, { optimizationLevel = 1, debugLevel = 2 })
Fixes that work
All of these compile cleanly at debug level 2 in our tests, with the same total number of values as the files that failed.
1. Scope locals with do blocks
A local declared inside a do ... end block frees its register when the block ends. Setup code that builds one part of the UI and does not need its locals afterwards fits naturally in a block:
local Hud = {}
do
local frame = makeFrame()
local label = makeLabel(frame)
local icon = makeIcon(frame)
Hud.stamina = label
end
do
local frame = makeFrame()
-- these locals reuse the registers freed above
Hud.coins = makeLabel(frame)
end
We compiled a file with three blocks of 150 locals each, 450 locals in total, at debug level 2. It passed, because no more than about 150 are live at once.
2. Group constants in a table
A table of constants takes one local, however many keys it has:
local Config = {
MAX_SPEED = 24,
CATCH_WINDUP = 0.8,
BRAND = "MEGA MART",
-- 400 more keys, still one register
}
You pay a table lookup on each use. For tuning values read a few times per frame, that cost does not matter. For a tight inner loop, copy the value into a local inside the function that runs the loop.
3. Move code into functions
Every function has its own budget of 200. Top-level setup code that runs once can move into a setup() function, and its locals stop counting against the file:
local function buildVitals()
local bar = makeBar()
local fill = makeFill(bar)
-- up to 200 locals of its own
return bar
end
local vitals = buildVitals()
This is the change we make most often in big client scripts. It also makes the script easier to read, because each part of the HUD is built in one place.
4. Split the module
If a file has 200 top-level things that really need to be top-level, the file is doing too much. In Shopping Cart Mayhem, the touch button layout, the tutorial, the item icons and the vote UI each live in their own module under src/client, and the HUD script requires them. Each module gets its own budget.
Put the check in CI
The real fix is to never ship this error again. We now compile every Luau file at debug level 2 as part of our test run, with a short Lune script:
local fs = require("@lune/fs")
local luau = require("@lune/luau")
local failed = {}
local function walk(dir: string)
for _, name in fs.readDir(dir) do
local path = dir .. "/" .. name
if fs.isDir(path) then
walk(path)
elseif path:match("%.luau$") then
-- debugLevel 2 like Studio: at level 1 constant locals fold away and the limit hides.
local ok, err = pcall(luau.compile, fs.readFile(path), { optimizationLevel = 1, debugLevel = 2 })
if not ok then
table.insert(failed, path .. ": " .. tostring(err))
end
end
end
end
walk("src")
walk("test")
assert(#failed == 0, table.concat(failed, "\n"))
print("compile ok")
Run it with lune run test/compile.lune.luau. It takes a second and catches what the type checker cannot see. Two habits make it stronger:
- Keep a known-bad canary. Keep one file with 201 constant locals outside
src, and check that the script fails on it. If a tool update changes the defaults, the canary tells you. - Know which files are close. We note in a comment at the top of any script that sits near the limit, so the next person adds new code inside a function instead of at the top level.
Summary
- Luau allows 200 local registers per function, and a script's top level is one function.
- At the default compile settings constant locals fold away, so the limit can hide.
- In our experience Studio compiles at debug level 2, where constants keep their registers, so a file can break only in Studio.
- Reproduce with
luau-compile --binary -g2or Lune'sluau.compilewithdebugLevel = 2, and check your check against a known-bad file. - Fix with
doblocks, constant tables, functions and smaller modules, then add the compile check to CI.
For the full toolchain we use around this (Rojo, Lune and the Studio test runs), see how to make a Roblox game in 2026. For more lessons from the same HUD script, read our mobile-first design notes.