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 assignments — x += 1, s ..= "foo", etc.
  • String interpolation — `Hello, ${name}!`
  • Generalized iteration — for k, v in t do works on tables without calling pairs (respects __iter)
  • Optional type annotations — local 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). A few APIs return the separate integer type
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 integers

A few values are identifiers that need all 64 bits, so they come back as a Luau integer: a separate 64-bit type, not a regular number. A number is exact only up to ±9,007,199,254,740,991 (2^53 − 1).

  • Equip sn, essence_sn and cash_item_sn from the inventory functions
  • Locker cash_sn from core.cash_shop.locker.get_items()
  • packet:decode_8()
  • A whole number beyond ±2^53 read back from core.settings.storage

Everything else returns a regular number, including meso, EXP, timestamps, decode_1/2/4 and core.get_setting.

These work on an integer:

  • == and ~= against another integer, including literals written with an i suffix such as 0i
  • .. and tostring
  • Arithmetic. +, -, *, // and % give an integer when the other side is an integer or a whole number, wrapping around past the 64-bit range, so sn + 1 stays exact. With a fractional number, and for / and ^, the result is a number.
  • <, >, <= and >= between two integers

These don't:

  • Comparing with a plain number: sn < 5 raises an error, and sn == 0 is always false. Compare with 0i instead.
  • math.*, string.rep and for loop limits. Convert first with integer.tonumber.
  • tonumber(sn), which returns nil. Use integer.tonumber(sn), which is exact up to 2^53.
  • Table keys: t[5] and t[5i] are different keys.
lua
for _, item in ipairs(core.inventory.get_equip_items()) do
    core.log("Serial: " .. item.sn)          -- ok
    if item.essence_sn == 0i then end        -- ok: the equip has no essence
    if item.essence_sn == 0 then end         -- always false: an integer never equals a number
end

The integer library works on integers and also accepts whole numbers, so integer.gt(10, item.sn) and integer.tonumber(5) both work.

Further reading