Skip to content

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 nobody

An 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)
end

The 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

Damage · damage · heal

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,
})
KeyMeaning
predictup to 8 rules, tried top to bottom; the first whose when holds is the one that plays
serverthe real handler, for anything a description cannot express

Each rule may carry:

KeyMeaning
whena condition over the object's own fields. "open", { "not", "open" }, { "==", "mode", "stopped" }, { ">", "visits", 10 }, { "<", ... }. Omitted = always
tweento, rot, scale, seconds, ease — the same words as tween
setfields to write, the same way set does
sounda 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,
})
KeyTypeMeaning
radiusnumbermetres, clamped to 0.5 – 96. 0 (or omitted) removes the volume
nearfunctionsomeone entered
farfunctionsomeone 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 shotMeaning
poswhere their feet were
eyewhere their eyes were, for their stance — cast from here
dirwhere the shot is going: a unit direction, with recoil and shot cone already applied
downtrue 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

KeyTypeMeaning
keystringthe suggested key: "mouse1", "mouse2", "r", "space", a letter, a digit
releasefunctionruns when the button is released — for hold-to-aim, charge-and-release
cameratablewhat the camera does while held — see camera presets
recoiltablehow firing kicks the view — see recoil
autonumberseconds 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)
ArgumentMeaning
playerwho clicked. Carries show_hud / update_hud / hide_hud, so you can answer by changing the menu
eventthe name the button's emit used
datathe 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 round

rotate_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)
end

Answers 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 taller

tween

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",
})
KeyTypeMeaning
topointfinal position
rotpointfinal absolute orientation, degrees
scalenumber or pointfinal size
secondsnumberrequired, clamped to 0.05 – 600
easestring"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 run

Starting 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 hop

Mass-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)
end

particles

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
KeyMeaning
bythe 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)
KeyTypeMeaning
playerstringrequired — the player id
atstringwhich socket. "right_hand" (default), "left_hand", "back", "head", "waist", "chest" and the rest — see Sockets
pospointwhere it sits on the socket, in metres
rotpointhow it is turned on the socket, in degrees
scalenumber or pointmultiplies the size it already has
smoothnumberseconds to slide there from wherever it was. 0 snaps
settlebooleandeclares the landing for the whole carry — see below
fptablea 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

ReasonMeaning
"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,
})
KeyTypeMeaning
throwpointvelocity on the way out, m/s
physicsbooleansee the table below
settlebooleanphysical only while it falls
What you passWhat happens
physics = truefalls and tumbles, and stays a body you can shove around forever
physics = falsestands on the ground and stays put
neitherwhatever it was before it was picked up
settle = truedrops 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"
end

on_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:

ReasonMeaning
"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 } })
KeyTypeMeaning
volumenumber0 – 4. 1 is the level it was recorded at
pitchnumber or {low, high}0.25 – 4. The pair picks one value per play
rangenumbermetres past which nobody hears it
nearnumbermetres inside which it does not get any quieter
falloffstring"inverse" (how the real world works) or "linear" (a zone with a definite edge)
airnumber0 – 1, how much of the top end distance eats
distantstringanother sound in this object that takes over further out
fadenumberseconds to fade in. 0 is a cut
loopbooleankeep playing — but prefer sound
channelstringnames the emitter, so one object can run several

Three of these are worth understanding rather than copying:

  • pitch as 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.
  • near matters more than it looks: without it, a sound you are standing on is infinitely loud.
  • distant is 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)
end

A 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" })
end

store

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 key

Both 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:

objectpersonal HUD
key length256 B256 B
value size64 KB8 KB
number of keys25664
total1 MB32 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
VerbAnswers
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

Hungrit scripting documentation.