Violet

Luau Language Notes

On this page

Violet scripts run on Luau, Roblox's fork of Lua. Luau is based on Lua 5.1 with selected features from later versions and its own extensions. If you've written Lua before — especially targeting 5.4 — there are a few differences to be aware of.

What you gain over Lua 5.1

  • continue inside loops
  • Compound assignmentsx += 1, s ..= "foo", etc.
  • String interpolation`Hello, ${name}!`
  • Generalized iterationfor k, v in t do works on tables without calling pairs (respects __iter)
  • Optional type annotationslocal x: number = 1 (ignored at runtime, useful if you run the Luau analyzer)
  • buffer type — native mutable byte arrays, see buffer library. in_packet:decode_buffer returns one.
  • Sandbox-safe by default — scripts can't escape into os.execute, io.open, debug.getfenv, etc. (None of those are exposed.)

What's missing vs Lua 5.2+/5.4

Scripts ported from newer Lua will need adjustments for:

Lua 5.2+ featureLuau equivalent
Native bitwise operators |, &, ~, <<, >>Use the bit32 library
string.pack / string.unpackEncode manually, or use buffer + in_packet / out_packet helpers
utf8 libraryNot available
<const> / <close> variable attributesNot supported; plain local only
Integer subtype (math.type, //)Luau numbers are doubles (// works as integer division)
goto / ::label::Luau supports continue instead for the common use case

Globals and hook chaining

All loaded plugins share a single global table. Top-level assignments in one script are visible to others.

Hooks are chained, not replaced. Every plugin's on_tick (and every other lifecycle hook) is stored independently when the plugin loads, so all of them fire in load order every tick. Two plugins defining on_tick means both run — not that the second overwrites the first.

Block-style hooks follow first-wins semantics:

  • on_packet_recv / on_packet_send — first plugin to return false drops the packet; later plugins don't see it
  • on_dialog — first plugin to return a non-nil, non-true value intercepts the dialog; later plugins don't see it

For private helpers, use local at file scope — they stay scoped to the file:

lua
local function helper()
    -- only this file can see `helper`
end

function on_tick()  -- still a global, becomes the script's hook
    helper()
end

Or put shared utilities under scripts/libs/ and pull them in with require("my_lib") — module returns are cached, one copy per require path.

Error handling

Script errors propagate through pcall / xpcall normally. Uncaught errors from a hook are logged as [LUA ERROR] in <hook>: <message> and the hook is skipped for that tick — other plugins continue running.

When calling C-side bindings, failures surface as Lua errors (catchable with pcall). Calling a binding with the wrong argument type or on an invalid object raises an error; guarding with is_valid() on objects you've held across ticks is a good habit.

Performance

  • Luau's interpreter is ~2–3× faster than vanilla Lua 5.1 on typical hot loops.
  • Native codegen (enabled by default) compiles loaded chunks to machine code for x64 — no action required in your scripts.
  • buffer operations avoid the overhead of Lua table allocations for byte-level work; prefer buffer over tables when processing packet payloads.

64-bit packet integers

in_packet:decode_8 returns a Luau integer (a distinct int64 type, not a regular number). This preserves the full 64-bit pattern of fields like object IDs, timestamps, and hashes — a regular number (double) would silently round bits 54-64.

The integer type does not implicitly coerce against number for comparison or arithmetic:

lua
local id = packet:decode_8()
if id > 0 then end                  -- error: attempt to compare integer with number
if id == 0 then end                 -- always false (different types compare unequal)

Work with int64 values explicitly via the integer library and integer.new(...) for literals:

lua
local id = packet:decode_8()
if id > integer.new(0) then end     -- ok
if id == integer.new(0) then end    -- ok

-- Drop to a regular number if you don't need the high bits:
local n = tonumber(id)              -- precision lost past 2^53

All other numeric bindings (player:get_exp(), player:get_meso(), decode_1/2/4, etc.) return regular number values and can be mixed freely with plain numeric literals.

Further reading