SUNC Reflection Deep Dive
The reflection primitives — getgenv, getrenv, getsenv, getreg, getloadedmodules, getrunningscripts — let scripts inspect and modify Lua environments that aren't their own. They sit at the heart of cross-script communication, lurking-script discovery, and module re-use without recompilation.
Three environments worth distinguishing
When you run a script in an executor, three Lua tables conceptually hold globals:
- Executor environment (genv) — the executor's shared globals. Survives across executions. Where SUNC functions live.
- Roblox environment (renv) — Roblox's own global table. Contains
game,workspace, the standard library, all built-in services. - Script environment (senv) — the environment of a specific script (yours or someone else's). Contains that script's locals after it ran, plus a chain back to the renv.
The four functions below give you each table.
getgenv
getgenv(): { [string]: any }tablegetgenv().myCache = { items = {} }
-- Visible to any subsequent script run inside the executor.
-- Later, from a different script:
print(getgenv().myCache.items)The main use case: persisting state across script reloads. When the user re-pastes your loader, your new script starts with a fresh local environment — but getgenv() still has whatever you stashed there last time. That's how the "tear down previous instance" pattern works in the external script tutorial.
getrenv
getrenv(): { [string]: any }tablelocal renv = getrenv()
print(renv.game == game) --> true
print(renv.print == print) --> false (your script's print may be sandboxed differently)The reason these can differ: executors sometimes hand scripts a wrapper around print that routes to their own console, while Roblox's renv still has the original built-in. getrenv is how you reach the real one.
getsenv
getsenv(script: LuaSourceContainer): { [string]: any }tablelocal serverScript = game.ServerScriptService.Combat
local env = getsenv(serverScript)
print(env.MAX_HEALTH)
print(env.damagePlayer)
-- Whatever Combat declared as globals (not local), you can read here.The most common use: reading constants or helper functions out of a server-side script you didn't write, so your script can match their conventions. If a game script declares damagePlayer as a global function (not local), getsenv exposes it to your script like any other table entry.
debug.getupvalues on a function from that script.getreg
getreg(): { [any]: any }tableThe registry is mostly an internal data structure, but some things you might enumerate here are useful: coroutine references for every active thread, weakrefs to loaded modules, callback handles. The registry is one of the lowest-level entry points the Luau VM exposes.
Practical example: enumerate every active coroutine in the VM to find a hung one.
for k, v in getreg() do
if type(v) == "thread" then
print("thread", coroutine.status(v))
end
endgetloadedmodules
getloadedmodules(): { ModuleScript }{ ModuleScript }for _, module in getloadedmodules() do
print(module:GetFullName())
end
-- Prints every module the game has loaded, regardless of where it lives.Because ModuleScripts cache their return value after firstrequire, this list tells you exactly which modules are "live" in the VM. Often you can re-require one of them to get the same table the game itself is using — a powerful primitive for modifying game internals without hooking.
local Balance
for _, m in getloadedmodules() do
if m.Name == "Balance" then
Balance = require(m)
break
end
end
if Balance and Balance.PlayerWalkSpeed then
Balance.PlayerWalkSpeed = 32 -- mutate the live config
endgetrunningscripts
getrunningscripts(): { LuaSourceContainer }{ LuaSourceContainer }for _, s in getrunningscripts() do
print(s.ClassName, s:GetFullName())
endThe catch-all enumeration. Useful when you want to scan a game's scripts to find ones matching a pattern (by name, by source, by location). Often paired with getscriptbytecode or getscriptclosure to actually inspect or interact with a specific script.
A worked example: live tweaking a game
Putting the reflection primitives together: find a game's balance module, mutate a value, and have it take effect immediately because the game holds a reference to the same table.
local function findModule(name: string)
for _, m in getloadedmodules() do
if m.Name == name then return require(m) end
end
end
local Constants = findModule("GameConstants")
if Constants then
Constants.JumpPower = 100
-- Anywhere in the game that reads Constants.JumpPower now sees 100,
-- because we're mutating the same table the game uses.
endThe reason this works is identity. ModuleScripts cache their return value, so every require of the same module gets the same table reference. Mutate the table, and every cached reference sees the change.
Caveats
- Mutations to
getrenv()often don't propagate to Roblox-side scripts because they hold their own per-script environments chained off it. Useful for reading; less useful for writing. getsenvon a script that errored out returns the partial environment as of the failure point. That can be confusing if you expect a fully-populated table.- Some executors gate
getregbehind a flag because it can be slow on large heaps. Don't call it on a hot path.
Wrap-up
Reflection in SUNC is small but powerful. The whole surface is six functions; the patterns built on top (cross-execution state, module-table mutation, script discovery) cover most of what advanced scripts do. If you've never reached past getgenv before, spending fifteen minutes with the others will permanently expand the set of things you can build.
Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.