Devlog / Transmission
RobloxLuauScriptingTooling

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.

6 min readTumblix Studios

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, every require, every remote, every UI reference, every constant.
  • Big client scripts grow at the top level. A HUD script collects one local per 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:

FileDefault (-O1 -g1)-g2-O0
199 constant localsokokok
201 constant localsokfailfail
250 constant localsokfailfail
201 non-constant locals (local V = tick())failfailfail

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 -g2 or Lune's luau.compile with debugLevel = 2, and check your check against a known-bad file.
  • Fix with do blocks, 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.

// Next transmissions

Keep reading