Luau in an hour#
The language scripts are written in is called Luau. This page teaches enough of it to build real things, and it assumes you have never programmed before.
Read it once, build something, then come back. Nobody remembers a language from one pass.
If you have written JavaScript, Python or Lua, skim it and stop at the boxes marked worth knowing. Those are the parts that differ.
Values#
A value is a piece of information. There are four kinds you will use.
local name = "Mira" -- text, called a stringlocal coins = 40 -- a numberlocal locked = true -- true or false, called a booleanlocal nothing = nil -- "there is nothing here"local means this belongs to me. Always write it. A variable without local is visible to your whole script, and that is a reliable source of confusing bugs.
Numbers are just numbers. There is no separate whole number type. 3 and 3.5 are the same kind of thing.
nil is not zero and it is not false. It means absent. Reading something that was never set gives you nil, which is why so many examples check for it.
Doing things with values#
local coins = 40local total = 10 + 5 -- 15local half = 10 / 4 -- 2.5local rest = 10 % 3 -- 1, the remainder local greeting = "Hello, " .. "Mira"print(greeting, total, half, rest, coins)Joining text uses two dots, not +. Adding a number to text is an error, so convert it first:
local coins = 40print("You have " .. tostring(coins) .. " coins")Shorthands you will see everywhere:
local coins = 40coins += 1 -- same as coins = coins + 1coins -= 5coins *= 2Making decisions#
local coins = 40if coins >= 100 then print("rich")elseif coins >= 10 then print("getting there")else print("broke")endThe comparisons are == (equal), ~= (not equal, not !=), <, >, <= and >=.
Combining them:
if locked and coins >= 10 then ... endif locked or coins >= 10 then ... endif not locked then ... endWorth knowing
Only false and nil count as "no". Zero is true in a condition, and so is an empty string. if coins then is true even when coins is 0, so write if coins > 0 then when that is what you actually mean.
The short form#
This turns up constantly in real scripts:
local open = truelocal angle = open and 90 or 0print(angle)Read it as 90 if open, otherwise 0. It does the same job as the four line if, and it is safe as long as the middle value is never false or nil.
Repeating things#
for i = 1, 5 do print(i) -- 1, 2, 3, 4, 5end for i = 10, 0, -2 do print(i) -- 10, 8, 6, 4, 2, 0endNever write a loop with no end
while true do ... end burns through your script's instruction budget and the script gets killed. It stops running until you save it again.
Anything that should happen over and over in time is a timer, not a loop:
local self = require("self") self:on_timer(1, function() -- once a second, forever, and free in betweenend)Functions#
A function is a named piece of behaviour you can run more than once.
local function greet(who: string) print("Hello, " .. who)end greet("Mira")greet("Ash")They can hand a value back:
local function double(n: number): number return n * 2end local x = double(21) -- 42print(x)Functions handed to other functions#
This is the shape every event uses, so it is worth a moment.
local self = require("self") self:on_touch(function(player) print(player.name)end)function(player) ... end with no name is a function being handed to on_touch, which keeps it and calls it later. Written out the long way, it is the same thing:
local self = require("self") local function whenTouched(player) print(player.name)end self:on_touch(whenTouched)Both are fine. The short one is what you will mostly see.
Tables#
A table holds several values at once. It is the only container Luau has, and it does two different jobs.
As a list#
local colors = { "red", "green", "blue" } print(colors[1]) -- "red". Counting starts at 1, not 0print(#colors) -- 3. The # means "how many" table.insert(colors, "yellow")table.remove(colors, 1) -- takes out "red" for i, c in ipairs(colors) do print(i .. ": " .. c)endAs a set of named fields#
local door = { open = false, speed = 0.6,} print(door.open) -- falsedoor.open = trueThis second form is what every option in this API uses:
local self = require("self") self:tween({ rot = { x = 0, y = 90, z = 0 }, seconds = 0.6, ease = "in_out" })Worth knowing
When a function takes exactly one table, you may drop the parentheses. These two lines are identical:
local self = require("self")local here = self:position() self:tween({ seconds = 1, to = here })self:tween{ seconds = 1, to = here }Both spellings appear in these docs. Neither is more correct than the other.
Dots and colons#
This one trips up everybody exactly once.
self:move_to(0, 5, 0) -- colon: telling self to DO somethingself.store -- dot: reading a value OUT of selfUse : when something should act. Use . when you want a value. If you ever see attempt to call a nil value, a swapped dot and colon is the first thing to check.
Types, which are optional and worth it#
The --!strict on line 1 turns on checking. After that you can say what kind of value something is:
--!strict local function damage(amount: number, target: string): boolean print(target) return amount > 0endYou never have to annotate anything. What --!strict buys you is that your editor catches typos and wrong arguments before you save, instead of you finding them by clicking around the world wondering why nothing happens.
The one habit worth forming#
Many calls answer with a value or nil plus a reason. Reading a field out of nil is far and away the most common script error.
local self = require("self") local part = self:part("hinge")if not part then print("no part called 'hinge'") returnend part:rotate_by(0, 5, 0) -- safe, because we know it is thereThe pattern is always the same. Check, leave early, then carry on. It keeps the interesting code out of a nest of ifs.
Two shorthands for defaults:
local self = require("self") local n = tonumber(self.store:get("count") or "0") or 0print(n)a or b means a, unless it is nil or false, in which case b. Here it survives both a missing saved value and saved text that is not a number.
Built-ins you will actually use#
math.floor(3.7) -- 3math.ceil(3.2) -- 4math.abs(-5) -- 5math.min(3, 9) -- 3math.max(3, 9) -- 9math.random(1, 6) -- a whole number from 1 to 6math.random() -- a fraction from 0 to 1 tostring(42) -- "42"tonumber("42") -- 42tonumber("banana") -- nil string.upper("hi") -- "HI"string.format("%.1f", 3.14159) -- "3.1" #"hello" -- 5, the length of the textComments#
-- a note to yourself. The engine skips it. --[[ several lines]]Putting it together#
A lamp that starts off, comes on when somebody walks up, and switches itself off a few seconds after the last person leaves:
--!strictlocal self = require("self") local inside = 0local offTimer = nil local function setLight(on: boolean) self:set({ light = on, emissive = on and 2 or 0, color = on and "#ffe9b0" or "#3a3a3a", })end setLight(false) self:on_region({ radius = 6, near = function(player) inside += 1 if offTimer then offTimer:cancel() offTimer = nil end setLight(true) end, far = function(player) inside -= 1 if inside > 0 then return end offTimer = self:after(4, function() offTimer = nil setLight(false) end) end,})Every idea in it is on this page: variables, a function, if, the short form, a table of options, and functions handed to other functions. The counter exists because two people can be standing in the circle, and the light should only go out when the second one leaves.
Next#
- How scripts run covers slices, saving and budgets
- Recipes are these same ideas applied to real things
Updated 04 September 2026