Centurion Macros (CTN_) Explained, With Roblox Examples
A practical guide to the Centurion obfuscator's CTN_ macros: CTN_OBFUSCATED, CTN_ENCRYPT_STRING, CTN_NO_VIRTUALIZE, CTN_JIT, CTN_CRASH and more, with examples and common mistakes.
LuaProtect's standard builds run on the Centurion obfuscation engine, so the Centurion macros work in any script you protect with us. Macros are special names you write in your Luau source. The obfuscator recognizes them at build time and replaces them with generated code. You don't turn anything on; you just write them.
This guide covers the ones you'll use most, with the mistakes people make. For the full reference, including macros not covered here, see Centurion's own documentation.
Two kinds of macro
Macros come in two shapes, and mixing them up is the most common error.
- Expression macros are bare names you read, never call:
CTN_OBFUSCATED,CTN_VERSION,CTN_LINE. - Call macros are called like functions:
CTN_ENCRYPT_STRING("..."),CTN_CRASH(),CTN_NO_VIRTUALIZE(function() end).
One thing to remember about both: in raw, un-obfuscated source, every macro is just an undefined global. Expression macros read as nil, and calling a call macro raises "attempt to call a nil value". Keep that in mind whenever you test your script before obfuscating it.
CTN_OBFUSCATED: dev code that never ships
CTN_OBFUSCATED is true in an obfuscated build and nil in raw source. The useful part: it's resolved at build time, so a branch that can't run is removed from the output completely.
if not CTN_OBFUSCATED then
-- Only runs while you're testing raw source.
-- None of this ends up in the protected build.
print("debug mode")
end
Use it to keep test code, debug prints and internal URLs out of what players receive.
It's also how you safely use call macros in a script you sometimes run raw:
if CTN_OBFUSCATED then
CTN_CLEAR_STACK()
end
CTN_ENCRYPT_STRING and CTN_ENCRYPT_NUMBER: hide the values that matter
Every string in your script is already encrypted as part of the build. CTN_ENCRYPT_STRING adds an extra, dedicated encryption layer to one string, decrypted at runtime.
local Endpoint = CTN_ENCRYPT_STRING("https://api.example.com/secret")
CTN_ENCRYPT_NUMBER does the same for a number, so the constant doesn't appear directly in the output.
local MaxTries = CTN_ENCRYPT_NUMBER(3)
Two rules:
- Literals only.
CTN_ENCRYPT_STRING(name)orCTN_ENCRYPT_STRING("a" .. "b")is a build error. It has to be a single string literal, and the number version a single number literal. - Use them sparingly. Each one carries a small runtime and size cost. Save them for the few values an attacker would actually look for.
CTN_NO_VIRTUALIZE and CTN_JIT: when speed matters
A VM obfuscator runs your code inside a generated virtual machine. That's what makes it hard to read, and it also makes it slower. We explain why in how VM obfuscation works. These two macros give you control over that tradeoff.
CTN_NO_VIRTUALIZE takes a function literal and produces a native, non-virtualized version that runs as ordinary Lua at full speed.
local Dot = CTN_NO_VIRTUALIZE(function(Ax, Ay, Bx, By)
return Ax * Bx + Ay * By
end)
The function gets less protection, so only use it for performance-heavy code with nothing sensitive in it, like math in a render loop.
CTN_JIT marks a function for faster execution while keeping it inside the VM. It's the middle ground: faster than the default, still virtualized. It also applies to functions defined inside the marked one.
local ProcessFrame = CTN_JIT(function(Entities)
local Total = 0
for I = 1, #Entities do
Total = Total + Entities[I].Value
end
return Total
end)
A good rule: try CTN_JIT first. Reach for CTN_NO_VIRTUALIZE only if that's still too slow and the code isn't sensitive.
Both need a function literal. Passing an existing function by name is a build error.
CTN_CRASH: the fail branch of your own checks
CTN_CRASH() takes the VM down at that point. Execution doesn't continue, pcall won't catch it, and there's no error message. In Roblox, expect the thread to hang rather than raise.
if CTN_OBFUSCATED and GetScriptHash() ~= ExpectedHash then
CTN_CRASH()
end
Common mistake: using it to refuse to run un-obfuscated.
-- Wrong
if not CTN_OBFUSCATED then
CTN_CRASH()
end
That branch only runs in raw source, where CTN_CRASH doesn't exist, and in the build the whole branch is removed. Use a plain error("...") there instead.
CTN_CLEAR_STACK: wipe secrets after use
CTN_CLEAR_STACK() sets every local the current function has declared so far to nil, so a secret you've finished using isn't sitting in memory for someone to read out of a dump.
local Key = FetchKey()
UseKey(Key)
CTN_CLEAR_STACK()
-- Key is now nil
Parameters, upvalues, globals and table fields survive. Only locals are cleared. If you need a result afterwards, put it in a table first.
Coming from Luraph or LuaProtect's legacy macros
If your script uses LPH_ macros, or LuaProtect's older CRX_ macros, those are only supported on the Legacy build preset. For standard builds, swap them for the Centurion equivalents:
CRX_OBFUSCATED/LPH_OBFUSCATED→CTN_OBFUSCATEDCRX_ENCRYPT_STRING/LPH_ENCSTR→CTN_ENCRYPT_STRINGCRX_ENCRYPT_NUMBER/LPH_ENCNUM→CTN_ENCRYPT_NUMBERCRX_NO_VIRTUALIZE/LPH_NO_VIRTUALIZE→CTN_NO_VIRTUALIZECRX_CRASH/LPH_CRASH→CTN_CRASH
Quick reference
CTN_OBFUSCATED(expression): keep dev-only code out of the build.CTN_ENCRYPT_STRING("..."): extra encryption on a sensitive string literal.CTN_ENCRYPT_NUMBER(n): hide a meaningful number literal.CTN_JIT(function ... end): faster code that stays virtualized.CTN_NO_VIRTUALIZE(function ... end): full-speed native code, less protection.CTN_CRASH(): the fail branch of your own integrity checks.CTN_CLEAR_STACK(): wipe secrets from locals after use.
Using Centurion with a key system
LuaProtect builds your script with Centurion and then handles everything around it: a key system with HWID locks, bans that kick live players, key flows, a Discord bot and live analytics. Our Centurion obfuscator page explains how the two fit together.