Hungrit Docs

self#

self is the object your script is inside. Everything an object can do to itself is here.

Use it to

  • make it react when someone clicks it, walks into it or comes near
  • move it, turn it or glide it smoothly
  • change its colour, its light or its physics
  • let people pick it up, carry it or sit on it
  • play sounds, and remember things across restarts
  • break it, and mend it again
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.

At a glance#

What you want done, and the verb that does it.

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 pointlocal 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.

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

Careful

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 to 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, reason self: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

Tip

Always cast from shot.eye, never from shot.pos plus a number you chose. A number that is right standing up is wrong crouching, because 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 to 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.

Careful

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, because 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))    endend)
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) -- laterbeat: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, reason self: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 foreverend)

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, reason self:scale_to(x, y, z) → true, or nil, reason

One number resizes uniformly; three resize per axis. Clamped to 0.01 to 10000.

Luau
self:scale_to(2)            -- twice as bigself: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, so 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 to 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, set with 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.

velocity#

self:velocity() → point

How fast the object is moving right now, in metres per second. Only a dynamic object has a speed of its own; anything else reads zero. The reading is as fresh as position: the tick's, and zero on the very first call of a world's life, before the region has filled it.

Luau
-- glow while it is going fastself:on_timer(0.2, function()    local fast = self:velocity():len() > 12    self:set({ emissive = fast and 2 or 0 })end)

Another object's is obj:velocity().


Appearance#

set#

self:set(props) → true, or nil, reason

Writes properties, using 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, meaning colours, lights, physics, per-face painting and 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, reason self: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, so 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 campfireself:particles({ preset = "smoke", rate = 40, glow = 0 })

Start from a preset and override only what you care about. The full option table, all thirty-odd fields of it, 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, reason self: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

Tip

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, because 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 all end it, 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

Careful

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.

Tip

Ask the engine every time, and 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.


Being sat on#

The mirror of carrying: hold puts the object on the person, sit puts the person on the object.

A chair
self:on_touch(function(p)    self:sit(p)end)

That is a whole chair. Whoever clicks it sits; walking or jumping gets them up.

A chair that only needs this needs no script at all: switch on Seat in the editor's Physics page. On a part of a group, that part is the seat. A script on the same object still hears on_sit and on_stand.

The Seat settings are where the seat is (X, Y, Z), which way the sitter faces, whether a click sits, and what gets them up. They apply live: sit on it, then drag the sliders and watch your own avatar move. A script's self:sit uses them for every option it leaves out.

Hold E to sit
-- Seat settings: "Sit on click" OFF, so only this prompt seats anybody.local PROMPT = {    id = "sit", world = { height = 1.0, distance = 3, gaze = 25 },    size = { 100, 100 }, vars = { t = 0 },    pages = { main = {        { "ring", var = "t", size = 30, thickness = 2 },        { "text", "Hold E to sit", size = 10, align = "center" },        { "hold", key = "e", dur = 0.8, var = "t", emit = "sit" },    } },} self:on_region({    radius = 3,    near = function(p) p:show_hud(PROMPT) end,    far  = function(p) p:hide_hud("sit") end,}) self:on_hud(function(p, event)    if event == "sit" then        p:hide_hud("sit")        self:sit(p)    endend)

sit#

self:sit(player, options?) → true, or nil, reason

Pins that player to this object. If the object moves, they move with it.

player is the one a handler gave you (p), or their id.

OptionMeaning
poswhere on the object they sit, in the object's own space. vec(0, 0.45, 0) is 45 cm above its origin
yawwhich way they face, in degrees about the object's up. 0 faces the object's −Z
exitwhat gets them up: "move" (walk or jump), "jump" (only jump; the walk keys do nothing), "locked" (only a script)

Each one left out comes from the object's Seat settings, or else the default: the origin, 0, "move".

Luau
local vec = require("vec")local pose self:on_touch(function(p)    local ok, why = self:sit(p, { pos = vec(0, 0.45, 0) })    if ok then        pose = p:play_animation("sitting")       -- an animation in this object's Contents    endend) self:on_stand(function()    if pose then pose:stop() end    pose = nilend)

Sitting does not pose anybody. It decides where the body is; what the body looks like is play_animation, as above.

ReasonMeaning
permissionthey did not click this object, and the script's owner may not act on other people here
occupiedsomebody else is sitting on this object
player is requiredthe first argument was not a player
pos is too far from the objecta seat is on the thing. At most 8 m from its origin
yaw must be a number of degreesyaw was not a number
exit must be move, jump or lockedexit was another word

It can also come to nothing after answering true: the player has to be within 6 m of the seat when it happens, or sitting would be a teleport. Check seated or wait for on_sit if it matters.

Who may be sat

The click is the consent. A player who clicked this object, or pressed a button on a HUD it showed them, can be sat by it in any world, with no permission from anybody. Sitting somebody who did not interact is acting on their avatar and needs Act on other people's avatars.

The way out is always theirs. exit makes leaving deliberate, never impossible: holding jump for 2 seconds stands anybody up from any seat, even a "locked" one. The player is told how to get up when they sit.

One object seats one person. The seat is the object this script is in; if that is a part of a group, the seat moves with the whole group.

stand#

self:stand(player?) → true, or nil, "not seated here"

Gets up whoever is sitting on this object.

seated#

self:seated() → the id of whoever is sitting here, or nil

on_sit#

self:on_sit(function(player) … end) → handle

Somebody is now sitting on this object.

on_stand#

self:on_stand(function(player, why) … end) → handle

whyMeaning
"stood"they walked or jumped
"script"a script called stand, or sat them somewhere else
"gone"this object's root was deleted

When somebody leaves the region while sitting, the seat is simply free again and this is not called: there is nobody left to hand you.


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.

Tip

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, reason self: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 to 4. 1 is the level it was recorded at
pitchnumber or {low, high}0.25 to 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 to 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, because 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, reason self: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 is the object's own memory, which survives a restart.

Luau
self.store:set("visits", "1")local v = self.store:get("visits")     -- "1", or nilself.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 0n += 10self.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 is 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    endend)

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, because 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#

Updated 17 October 2026