Appearance
self
self is the object your script is inside. Everything an object can do to itself is here.
luau
local self = require("self")self also works without the require, as a ready-made global. The require is still worth writing: it is what gives you autocomplete and error checking in VS Code.
How answers come back
Almost every verb here answers in one of two shapes.
A reading hands you the value and never fails:
luau
local here = self:position() -- always a point
local who = self:held_by() -- a player id, or nil if nobodyAn action hands you true (or a useful value), or nil and a reason:
luau
local ok, why = self:move_to(0, 5, 0)
if not ok then
print("could not move: " .. why)
endThe reason is always plain text meant to be read. You will meet "permission", "too many commands", "not dynamic" and "not held" most often; the full list is in Limits and refusals.
Everything on this page
Reacting · on_touch · on_contact · on_contact_end · on_region · on_action · on_hud · on_timer · after
Moving · position · move_to · rotation · rotate_to · rotate_by · look_at · scale · scale_to · tween
Physics · push · set_velocity
Appearance · set · get · particles
Being carried · hold · drop · held_by · held_at · on_hold · on_drop
Sound · play_sound · sound · stop_sound
Pieces and memory · part · parts · store · contents · owner
Worn items only · on_attach · on_detach · on_world_enter
Objects can also draw on a player's screen — show_hud, show_crosshair, set_camera, set_weapon and the rest live on the player and are documented in Player and screen.
Reacting
on_touch
self:on_touch(fn) → handle self:on_touch({ predict = ..., server = fn }) → handle
Someone clicked the object, outside edit mode.
luau
self:on_touch(function(player)
print(player.name .. " clicked me")
end)The handler receives a Player.
Registering again replaces the previous handler — a script has one touch handler, not a list. In a group, clicking any piece bubbles up to the nearest ancestor that has a script.
The table form: reacting instantly
A click normally travels to the server and back, which is a visible pause. The table form lets you also describe what the click looks like, so the player's own machine plays it immediately while the real change happens on the server.
luau
self:on_touch({
predict = {
{ when = "open", set = { open = false },
tween = { rot = { x = 0, y = 0, z = 0 }, seconds = 0.6 } },
{ when = { "not", "open" }, set = { open = true },
tween = { rot = { x = 0, y = 90, z = 0 }, seconds = 0.6 } },
},
server = function(player)
print(player.name .. " used the door")
end,
})| Key | Meaning |
|---|---|
predict | up to 8 rules, tried top to bottom; the first whose when holds is the one that plays |
server | the real handler, for anything a description cannot express |
Each rule may carry:
| Key | Meaning |
|---|---|
when | a condition over the object's own fields. "open", { "not", "open" }, { "==", "mode", "stopped" }, { ">", "visits", 10 }, { "<", ... }. Omitted = always |
tween | to, rot, scale, seconds, ease — the same words as tween |
set | fields to write, the same way set does |
sound | a named sound, played to whoever clicked |
Do not predict something that can be refused. A locked door, a purchase, a raffle — if the server might say no, the player would see it happen and then snap back. Predict only what always succeeds.
on_contact
self:on_contact(fn) → handle
A player's body started touching the object — stepping on it, walking into it, hitting their head on it. This is physical contact, not a click.
luau
self:on_contact(function(player)
print(player.name .. " stepped on the plate")
end)Fires once per approach, not once per tick while they stand there. Pair it with on_contact_end to bracket "while touching".
Bubbles up through a group like on_touch. Last registration wins.
on_contact_end
self:on_contact_end(fn) → handle
The body stopped touching. Also fires when the player leaves the world while still touching, so every on_contact gets its pair.
on_region
self:on_region(opts) → true, or nil, reason
A sphere around the object. near fires the moment somebody comes inside it, far the moment they leave.
luau
self:on_region({
radius = 8,
near = function(player) print(player.name .. " arrived") end,
far = function(player) print(player.name .. " left") end,
})| Key | Type | Meaning |
|---|---|---|
radius | number | metres, clamped to 0.5 – 96. 0 (or omitted) removes the volume |
near | function | someone entered |
far | function | someone left |
At least one of near / far is required.
Notes that save you trouble:
- Both are edges, not states. They fire on the crossing, never repeatedly while someone stands inside.
- The sphere follows the object, so a moving platform brings its trigger.
- Leaving takes half a metre more than entering, so somebody standing exactly on the line does not flicker in and out.
- A player who disconnects inside still gets their
far. - Calling it again replaces the volume and re-announces whoever is already inside.
on_action
self:on_action(name, fn) → handle, or nil, reasonself:on_action(name, opts, fn) → handle, or nil, reason
Someone pressed a key the world declared. This is how a weapon fires, a vehicle accelerates, a torch lights.
luau
self:on_action("fire", { key = "mouse1" }, function(player, shot)
local hit = world:raycast({
from = shot.eye,
dir = shot.dir,
ignore_player = player.id,
})
end)The handler receives the player and a shot: where they were and where they were aiming at the exact instant the button went down.
Field of shot | Meaning |
|---|---|
pos | where their feet were |
eye | where their eyes were, for their stance — cast from here |
dir | where the shot is going: a unit direction, with recoil and shot cone already applied |
down | true on the press, false on the release |
Always cast from shot.eye, never from shot.pos plus a number you chose. A number that is right standing up is wrong crouching — the eye drops about 55 centimetres and your constant drops nothing, so the shot leaves half a metre above the crosshair.
Options
| Key | Type | Meaning |
|---|---|---|
key | string | the suggested key: "mouse1", "mouse2", "r", "space", a letter, a digit |
release | function | runs when the button is released — for hold-to-aim, charge-and-release |
camera | table | what the camera does while held — see camera presets |
recoil | table | how firing kicks the view — see recoil |
auto | number | seconds between repeats while held. Omitted = one press, one event. Clamped to 0.05 – 5 |
Fires on the edge of the press. Holding the trigger for two seconds is one event, not a hundred and twenty — unless you set auto.
An action belongs to the world, not to your object. Two objects declaring "fire" are one trigger: the player has one finger, not one per object in the room. That is why a weapon's recoil and rate of fire belong on set_weapon, which is per player, and not on the action, which everybody shares.
Names may use letters, digits, _ and -. A world may declare up to 32 actions. The server never learns which key was pressed, only that the named action happened — players can rebind freely.
on_hud
self:on_hud(fn) → handle
A button on a menu this object showed reported back.
luau
self:on_hud(function(player, event, data)
if event == "buy" then
print(player.name .. " bought " .. tostring(data.item))
end
end)| Argument | Meaning |
|---|---|
player | who clicked. Carries show_hud / update_hud / hide_hud, so you can answer by changing the menu |
event | the name the button's emit used |
data | the fields that button sent |
Only buttons with emit reach here. Changing pages, moving a slider and ticking a box all happen on the player's own machine and never touch your script. See Menus and HUDs.
Last registration wins.
on_timer
self:on_timer(seconds, fn) → timer handle
Runs fn every seconds, forever.
luau
local beat = self:on_timer(1, function()
self:rotate_by(0, 15, 0)
end)
-- later
beat:cancel()seconds must be greater than 0. The handle has cancel(), which is safe to call twice.
Timers do not fire while the region is asleep. See how scripts run.
after
self:after(seconds, fn) → timer handle
Runs fn once, later. A door that closes itself, a cooldown, a delayed explosion.
luau
self:after(4, function()
self:set({ light = false })
end)seconds must be 0 or more — after(0, ...) runs on the next tick, which is a useful way to let the current event finish first.
cancel() before it fires disarms it; after it fired it does nothing.
Moving
Coordinates are world coordinates when the object stands on its own, and local to the parent when it is inside a group. Angles are always degrees.
position
self:position() → point
Where the object is. Never fails.
luau
local here = self:position()
print(here.x .. ", " .. here.y .. ", " .. here.z)The result is a vec: it has x, y, z, it has maths operators, and it can be handed straight to anything that takes a point.
move_to
self:move_to(x, y, z) → true, or nil, reasonself:move_to(point) → true, or nil, reason
Puts the object somewhere, at once.
luau
self:move_to(0, 5, 0)
self:move_to(vec(0, 5, 0))
self:move_to(other_place)Coordinates are limited to 100 km from the origin.
rotation
self:rotation() → point
Current orientation in degrees, as a vec. A single-axis round trip is exact; an orientation built from several axes may come back as an equivalent trio of angles rather than the exact numbers you set.
rotate_to
self:rotate_to(x, y, z) → true, or nil, reason
Absolute orientation in degrees. y is the one that turns an object on the spot.
luau
self:rotate_to(0, 90, 0) -- face a quarter turn roundrotate_by
self:rotate_by(x, y, z) → true, or nil, reason
Relative turn, from wherever it is pointing now. This is what a hinge, a wheel or a spinning coin wants — repeated rotate_by calls add up cleanly and never lock up the way repeated absolute angles can.
luau
self:on_timer(0.05, function()
self:rotate_by(0, 6, 0) -- spins forever
end)look_at
self:look_at(x, y, z) → true, or nil, reason
Turns to face a point, staying upright: the object's forward side ends up pointing at the target, and only the horizontal direction is used — so looking at something overhead turns without tipping over.
luau
local target = players:get(id)
if target then
self:look_at(target.pos)
endAnswers nil, "look_at: target is not a direction" when the target is straight up, straight down, or the object's own position.
scale
self:scale() → point
Current size per axis, as a vec.
scale_to
self:scale_to(n) → true, or nil, reasonself:scale_to(x, y, z) → true, or nil, reason
One number resizes uniformly; three resize per axis. Clamped to 0.01 – 10000.
luau
self:scale_to(2) -- twice as big
self:scale_to(1, 3, 1) -- three times tallertween
self:tween(opts) → tween handle, or nil, reason
Moves, turns or resizes smoothly over time. The world runs the movement — your script does not stay awake during it, and nothing is sent per frame.
luau
self:tween({
to = { x = 0, y = 5, z = 0 },
rot = { x = 0, y = 180, z = 0 },
seconds = 2,
ease = "in_out",
})| Key | Type | Meaning |
|---|---|---|
to | point | final position |
rot | point | final absolute orientation, degrees |
scale | number or point | final size |
seconds | number | required, clamped to 0.05 – 600 |
ease | string | "linear" (default), "in", "out", "in_out" |
At least one of to / rot / scale is required.
The handle carries:
luau
local t = self:tween({ to = up, seconds = 1 })
t:on_done(function()
print("arrived")
end)
t:cancel() -- stop where it is; on_done does NOT runStarting a new tween replaces the running one, and an explicit move_to, rotate_to or scale_to cancels it. Only the active tween's handle responds; an old one becomes a silent no-op.
Physics
Both verbs need the object to be a physics body — self:set({ dynamic = true }) — otherwise they answer nil, "not dynamic".
push
self:push(x, y, z) → true, or nil, reason
An instant shove: adds to the object's velocity, in metres per second.
luau
self:push(0, 8, 0) -- a hopMass-independent on purpose — the same push moves a small crate and a big one equally, so you can tune a jump without also tuning every object's weight. Magnitude is clamped to 100 m/s.
set_velocity
self:set_velocity(x, y, z) → true, or nil, reason
Sets the velocity outright, rather than adding to it. A conveyor, a cannonball, or set_velocity(0, 0, 0) to stop something dead. Same 100 m/s clamp.
Appearance
set
self:set(props) → true, or nil, reason
Writes properties — the same vocabulary as the Edit window. One call is one change, however many keys it carries, so batch them.
luau
self:set({
color = "#55e38b",
emissive = 1.5,
light = true,
light_radius = 12,
})Up to 32 keys per call. The complete list of what you can write — colours, lights, physics, per-face painting, health — is in Object properties.
Colours accept "#rrggbb", "#rrggbbaa", a colour name, or { r, g, b }.
get
self:get(key) → value, or nil
Reads one property back. A property that was never written reads as nil, which means "the engine's default", not "zero".
luau
if self:get("dynamic") then
self:push(0, 6, 0)
endparticles
self:particles(opts) → true, or nil, reasonself:particles() → removes the emitter
Attaches fire, smoke, sparks or dust to the object. The emitter belongs to the object: it follows it and dies with it. Nothing is sent per particle — every viewer draws the same field from the description, so it costs the same for one watcher or forty.
luau
self:particles({ preset = "fire" }) -- a whole campfire
self:particles({ preset = "smoke", rate = 40, glow = 0 })Start from a preset and override only what you care about. The full option table — thirty-odd fields — is in Particles.
Damage
An object is damageable only if it has a max_health. Set it in the Edit window or with self:set({ max_health = 100 }). Without it, both verbs answer nil, "this object has no max_health — it cannot be damaged".
That is deliberate: it is how a world says which props are destructible and which are scenery.
damage
self:damage(amount) → remaining health, or nil, reasonself:damage(amount, { by = uuid }) → remaining health, or nil, reason
luau
local left = self:damage(25, { by = player.id })
if left and left <= 0 then
print("felled")
end| Key | Meaning |
|---|---|
by | the player id to credit, for a scoreboard or a kill feed |
The number you get back is a prediction. The subtraction itself happens on the server, because two axes hitting the same tree on the same tick would otherwise both read the same health and the tree would take one hit for two swings. What you receive is what your blow would leave — which is exactly what you need to decide whether you just felled it.
For the authoritative outcome, listen for world.destroyed. See Damage and destruction.
heal
self:heal(amount) → remaining health, or nil, reason
The same effect, negated. Same rules, same prediction.
Being carried
hold
self:hold(opts) → true, or nil, reason
Puts this object into a player's hand.
luau
self:on_touch(function(p)
self:hold({ player = p.id, at = "right_hand" })
end)| Key | Type | Meaning |
|---|---|---|
player | string | required — the player id |
at | string | which socket. "right_hand" (default), "left_hand", "back", "head", "waist", "chest" and the rest — see Sockets |
pos | point | where it sits on the socket, in metres |
rot | point | how it is turned on the socket, in degrees |
scale | number or point | multiplies the size it already has |
smooth | number | seconds to slide there from wherever it was. 0 snaps |
settle | boolean | declares the landing for the whole carry — see below |
fp | table | a second grip, for the carrier's own first-person view — see below |
Calling hold again with a different at moves it between sockets. That is how a weapon comes off the back and into the hand; there is no second verb.
The axes are the same on every socket
+x is right, +y is up, −z is forward, relative to the body. The hand, the back and the first-person eye all agree, so a number you found for one is a real starting point for another.
Each socket still needs its own pose — something lies across a back differently from how it sits in a fist. Find the numbers with the grip window (</> → Pegada), which lists everything you are carrying and hands back a table to paste straight into this call.
The first-person grip
The person holding it does not see it where everybody else does, and that is not a bug to tune away: in third person the object hangs off the hand bone of a body you can see, and in first person that body is not drawn at all, so it hangs off the hands in front of the eye instead. Two different frames, and one set of numbers cannot be right in both. Tune the grip until the rifle sits well in everyone else's view and it is through your own chin; tune it for your own view and it is through everyone else's wrist.
fp is the second answer. Same three keys, same axes, same degrees:
luau
self:hold({
player = p.id,
at = "right_hand",
-- what everybody sees
pos = { x = 0.02, y = -0.01, z = 0.06 },
rot = { x = 0, y = 90, z = 0 },
-- …and what YOU see, holding it
fp = {
pos = { x = 0.14, y = -0.18, z = -0.35 },
rot = { x = -2, y = 88, z = 4 },
scale = 1,
},
})Leave fp out and first person keeps using the grip above — which is what every world that has never mentioned it already does. It changes nothing for anybody else: every other player draws the shared grip off the hand bone, whatever is in here.
It only applies where a viewmodel applies: your own screen, in first person, in a hand. An object on your back is not drawn to you at all, and an object in somebody else's hand is never yours to see this way.
What fp is measured from. If the player has first-person arms (set_viewmodel) and that model carries a skeleton, fp hangs off the model's own hand bone: move, turn or rescale the arms in the Braços tab and the weapon goes with them, and a gesture that pulls the hand down takes the gun along. Zero rotation means aligned with the arms, whatever twist the rigger left in that bone.
Without arms — or with arms that have no skeleton — there is no hand to hang from, so fp is measured from the eye, exactly as before.
Tune the arms first and the weapon second: the arms decide where the hand is, and the grip is where the weapon sits in it.
settle
settle = true says: be physical while it falls, ordinary geometry once it stops — however the carry ends.
Prefer it here rather than on drop. Three of the four ways a carry can end never call drop at all — the player pressing the put-down key, walking out of the region, or losing their connection — so a settle given only there silently does nothing on those, and an object that was already a physics body is left dynamic forever.
Refusals
| Reason | Meaning |
|---|---|
"permission" | that player has not interacted with this object. A touch, or a button on a HUD you showed them, counts |
"socket" | no attachment point by that name |
"child" | hold the root of a group, never one of its pieces |
"held" | somebody else has it. Taking it from their hands is a rule you write, never a default |
"full" | their hands are full |
The object stays in this region. It does not go into anybody's inventory and it does not follow them anywhere. Walk out carrying it and the region puts it down where you were standing — and your on_drop hears about it.
drop
self:drop(opts) → true, or nil, "not held"
Lets go. The object is released in front of the carrier at chest height, and physics takes it from there.
luau
self:drop({ settle = true })
self:drop({
throw = { x = look.x * 4, y = 2, z = look.z * 4 },
settle = true,
})| Key | Type | Meaning |
|---|---|---|
throw | point | velocity on the way out, m/s |
physics | boolean | see the table below |
settle | boolean | physical only while it falls |
| What you pass | What happens |
|---|---|
physics = true | falls and tumbles, and stays a body you can shove around forever |
physics = false | stands on the ground and stays put |
| neither | whatever it was before it was picked up |
settle = true | drops like physics = true, then goes back to being static the moment it stops |
settle is what most props want, and neither of the other two gives it: the default puts the object down without it ever falling, so a drop over a ledge lands on air. You do not need to pair it with physics = true — asking to settle is asking for the fall.
The region decides where it is released, never where it ends up.
held_by
self:held_by() → player id, or nil
Who is carrying it. Never fails.
held_at
self:held_at() → socket name, or nil
Which socket it is on: "right_hand", "back", and so on.
Ask the engine, every time — never keep your own copy. An object can leave a hand by routes your script never runs: the player pressing the put-down key, walking out of the region, losing their connection. A local variable saying "I am held" would go on saying it about an object lying on the floor.
luau
local function in_hand(): boolean
return self:held_at() == "right_hand"
endon_hold
self:on_hold(fn) → handle
Somebody picked this object up.
Fires for every hold, including one that moves the object to another socket. If you need "is it actually in a hand now", ask held_at() inside the handler rather than assuming.
on_drop
self:on_drop(fn) → handle
It left somebody's hands. The handler receives the player and a reason:
| Reason | Meaning |
|---|---|
"script" | you called self:drop |
"released" | the carrier put it down themselves. Always available to them, and no script can refuse it |
"left" | they are no longer in this region — includes a lost connection |
"gone" | the object itself was deleted while held |
Register this if the object matters to your game. Three of the four are not something you asked for, and they are what keeps your bookkeeping honest.
Sound
The name of a sound is either an item in this object's Contents, a path on the world's shared library (world/sounds/thunder), or one of the built-in cues that need no upload.
The slash is the whole difference. A bare name lives inside this object and travels with it — take it, sell it, drop it in another world, and the sound is still there. A world/… name lives in the world you are standing in: one copy fifty objects can share, swappable in one place, and nothing an object carries away with it.
play_sound
self:play_sound(name) → true, or nil, reasonself:play_sound(name, opts) → true, or nil, reason
Makes a noise at this object, heard by whoever is near enough. It follows the object.
luau
self:play_sound("hammer")
self:play_sound("hammer", { volume = 0.9, pitch = { 0.95, 1.05 } })| Key | Type | Meaning |
|---|---|---|
volume | number | 0 – 4. 1 is the level it was recorded at |
pitch | number or {low, high} | 0.25 – 4. The pair picks one value per play |
range | number | metres past which nobody hears it |
near | number | metres inside which it does not get any quieter |
falloff | string | "inverse" (how the real world works) or "linear" (a zone with a definite edge) |
air | number | 0 – 1, how much of the top end distance eats |
distant | string | another sound in this object that takes over further out |
fade | number | seconds to fade in. 0 is a cut |
loop | boolean | keep playing — but prefer sound |
channel | string | names the emitter, so one object can run several |
Three of these are worth understanding rather than copying:
pitchas a pair is what stops four identical samples a second from sounding like a machine. The region picks the value, so everybody in earshot hears the same one.nearmatters more than it looks: without it, a sound you are standing on is infinitely loud.distantis how far-away things really work. Something 300 m away is not its own close-up sound turned down; it is a different recording.
range is also the radius the server uses to decide who is even told about the sound, so it is a cost as well as a choice.
sound
self:sound(name, opts) → true, or nil, reason
The same thing with the loop already on: keep making this noise. A motor, a fire, a radio.
luau
self:sound("motor", { volume = 0.4, fade = 1.5, channel = "motor" })It plays until stop_sound, follows the object, and is heard by people who arrive later — a looping emitter is a property of the object, not an event that happened once.
Takes the same options as play_sound, minus loop.
channel names the emitter so one object can run more than one. Using the same channel twice replaces rather than stacking, which is what makes a script safe to re-run.
stop_sound
self:stop_sound() → true, or nil, reasonself:stop_sound(channel, fade) → true, or nil, reason
Ends a looping emitter. With no channel it stops the one self:sound started without naming one — the only emitter most objects have.
fade defaults to a short ramp rather than to zero, because a motor that stops dead sounds like a bug and a cut in a waveform is an audible click.
Pieces and memory
part
self:part(name) → part, or nil
One direct child of this object, by name. Repeated names give you the first one found.
luau
local lid = self:part("lid")
if lid then
lid:rotate_to(0, 0, -80)
endA part has its own small API: set, get, move_to, position, rotate_to, rotate_by, rotation, scale_to, scale. Its coordinates are local to the group. A part that has been deleted answers nil, "unknown part".
parts
self:parts() → list of parts
Every direct child.
luau
for _, p in ipairs(self:parts()) do
p:set({ color = "#883322" })
endstore
self.store — the object's own memory, which survives a restart.
luau
self.store:set("visits", "1")
local v = self.store:get("visits") -- "1", or nil
self.store:set("visits", nil) -- removes the keyBoth are fallible: get and set answer the value (or true) plus a reason on failure.
Values are always text. Convert on the way in and out:
luau
local n = tonumber(self.store:get("coins") or "0") or 0
n += 10
self.store:set("coins", tostring(n))The ceilings, and what happens at them:
| object | personal HUD | |
|---|---|---|
| key length | 256 B | 256 B |
| value size | 64 KB | 8 KB |
| number of keys | 256 | 64 |
| total | 1 MB | 32 KB |
A full store still reads, overwrites and deletes — only a brand new key is refused, and nothing of yours is erased behind your back. set answers nil, "store full" or nil, "value too large" rather than failing silently.
contents
self.contents — what the creator put inside this object.
luau
local item, why = self.contents:get("arrow")
if item then
world:spawn({ model = "arrow", pos = tip })
end
local n = self.contents:count()
for _, c in ipairs(self.contents:list() or {}) do
print(c.name .. " (" .. c.kind .. ")")
end| Verb | Answers |
|---|---|
list() | every item, in the order the creator filled it |
get(name) | one item, or nil with no reason — absent is an answer, not a failure |
count() | how many, without building the list |
Each item has a name and a kind ("mesh", "texture", "animation", "script", "sound", "object").
Read only. Putting something in is an act of a person with an inventory; a script has none of its own. What a script does with contents is use them — pass the name to world:spawn, self:play_sound, and so on.
There is no asset field and there never will be: a script refers to what the object holds by name, and the server resolves it when it is used. A name is what you wrote and what still reads correctly a year later.
Empty and harmless on a HUD or a worn item, which have no object to hold anything.
owner
self:owner() → player id
Who owns this object. Never fails.
luau
self:on_touch(function(p)
if p.id ~= self:owner() then
print("only the owner may use this")
return
end
end)Worn items only
A script on an item somebody wears gets a slightly different self: it has its own transform relative to the bone, memory that crosses worlds, and these three events. It has no push, set_velocity, on_touch or on_contact — a worn item is not in the world's object tree and has no collider.
on_attach
self:on_attach(fn) → handle
The item was put on, or this world just started the script.
on_detach
self:on_detach(fn) → handle
The item was taken off, or this world is releasing the script. It runs before the script is torn down, which makes it the last moment you can write to self.store.
on_world_enter
self:on_world_enter(fn) → handle
New world, old memory. A worn item's script is torn down on every world switch and started again on the other side; only self.store survives. This event names that instant.
See also
- world — the region: spawning, raycasts, weather, time
- players — who is here, and what they are doing
- Player and screen — HUDs, crosshair, camera, weapons
- Object properties — every key
setaccepts - Limits and refusals — every ceiling and every reason
