Appearance
players
Who is in the region right now, where they are, and what they are doing.
luau
local players = require("players")Reads here never touch the region — they come from a mirror the server refreshes on every event and tick — so asking is cheap and never fails.
Two spellings, one call
Every verb on this page that acts on one person can be written either way:
luau
players:play_animation(p.id, "world/avatars/anim/dancar")
p:play_animation("world/avatars/anim/dancar") -- the same callUse whichever fits what you are holding. If you already have the player — from a handler, from players:get, from a nearby loop — the second reads better and is one thing less to get wrong. If what you have is an id, use the first.
The player form is a forwarder into this module: same permission, same refusal, same everything. There is one implementation, so the two cannot drift.
It applies to play_animation, set_locomotion, set_movement, clear_movement, movement, dress, set_body, undress, teleport, worn and is_wearing — and the screen verbs (show_hud, set_camera, set_viewmodel…) have always been written on the player.
Naming an asset
Wherever a verb here takes an asset, it takes a name, and the rule is the same one the rest of the API follows:
| What you write | Where it is looked up |
|---|---|
"world/avatars/anim/dancar" | this world's library — the shelf |
"dancar" | this object's own contents |
| a 64-character hash | the asset itself, for scripts written before names |
A slash means the library and nothing else: a world/… path with a typo is refused on the line that wrote it rather than quietly finding something of the same spelling inside the object.
For a file that packs several clips, add #name (or #index): "world/avatars/pack#sacar". Prefer the name — re-exporting from Blender reorders the actions, and an index then points at another animation with nothing said.
Finding people
count
players:count() → number
How many people are here. Never fails, so you can use it straight in maths or text.
luau
print("players here: " .. players:count())list
players:list() → list of players
Everybody, with positions. Never fails.
nearby
players:nearby(radius) → list of players
Like list(), filtered by distance to the object this script is in.
luau
for _, p in ipairs(players:nearby(15)) do
p:show_hud(WARNING)
endradius is in metres and must be a real number above zero.
get
players:get(id) → player, or nil
One person, by their id or by their exact username, with a fresh position.
luau
self:on_touch(function(p)
local who = players:get(p.id)
if not who then return end
print("they are standing at " .. tostring(who.pos))
end)nil means they are not in this region.
Why look someone up when a handler already handed them to you? Because the player in a handler carries who they are, not where they are. players:get adds the position, velocity and aim. Both are the same person, and both carry the same screen verbs.
What a player carries
Who they are
| Field | Meaning |
|---|---|
id | the account's permanent id. Never changes. Use this as a key |
name | their display username. They can change it — never use it as a key |
num | their public number. 0 means an account without one |
What their body is doing
| Field | Meaning |
|---|---|
gait | "idle", "walk", "run", "jump" or "fall" |
speed | speed on the ground, m/s, without the vertical part |
grounded | are their feet on something? |
crouched | are they crouched? |
turn | how fast they are turning, degrees per second. Positive is to the left |
holding | world objects they are carrying right now, by id |
gait is the same word the client is drawing, so a script and the screen never disagree. It is never "land" — landing is a one-frame flourish, not a state a body stays in; watch for the exit from jump or fall instead.
Do not use gait to swap the leg animation. For that there is set_locomotion, which swaps the clip without taking the decision away from the player's own machine. gait is for game rules: don't aim while running, spend stamina, lose accuracy.
crouched is the stance the simulation settled on, not the key they are holding — somebody wedged under a low ceiling reads true with nothing pressed. It also decides their capsule height, so it is what a shot is tested against: a crouched player is a shorter target with a lower head.
holding is the region's own list, so it is always right. Keeping your own count in self.store instead drifts the first time somebody disconnects mid-carry.
Where they are
| Field | Meaning |
|---|---|
pos | where the player believes they are — the one you want |
seen | what the region actually saw, raw |
vel | velocity, m/s |
lag | one-way delay, in seconds, measured by the connection |
look | where their camera points: a direction |
Use pos, not seen. Every machine draws its own avatar by its own prediction, which is ahead of the region by the time its commands take to arrive. A script acting on the raw position acts on a place the person left lag seconds ago — always, and always to the same side. That is not noise, it is bias, and it is what makes a turret shoot behind a running player.
pos already has that difference projected, bounded in seconds and metres. A stationary player has pos == seen exactly.
look is where they are aiming, not where they are walking. The two coincide only by accident and separate exactly when it matters: someone strafing looks one way and moves another, and a stationary player has no walk direction at all. It is always a real direction, never zero.
lag is measured by the transport, not reported by the client — nobody can lie about their own latency to be aimed where they will not be.
Every player also carries the screen verbs — show_hud, show_crosshair, set_camera, set_weapon and the rest. They are documented in Player and screen.
Arriving and leaving
on_enter
players:on_enter(fn) → handle
Somebody came into the region. The list already includes them.
luau
players:on_enter(function(p)
print(p.name .. " arrived")
end)on_leave
players:on_leave(fn) → handle
Somebody left. The list already excludes them.
Acting on somebody
teleport
players:teleport(id, to) → true, or nil, reason
Moves a player somewhere else in this region, at once.
luau
players:teleport(p.id, { x = 20, y = 0.2, z = -20 })This is the primitive a respawn is made of. Everything else a death needs — what a head is worth, how long you stay down, what the scoreboard says — is a game rule a script can already hold. Moving the body was the one part no script could reach.
Needs this world's avatar body permission, which the player grants at the door. Without it you get nil, "permission" — a real answer, not a crash, so check it.
In-region only. Sending somebody to another world is a different act with a different consent question, and it is not this call.
The player feels no special message: their prediction and the next snapshot disagree by the whole distance, and a correction that large is already treated as a real teleport, so it snaps rather than sliding.
play_animation
players:play_animation(id, asset, options) → handle, or nil, reasonp:play_animation(asset, options) → the same
Starts an independent, whole-body animation layer on somebody. Everyone sees it, and a script layer takes priority over their walking and idling.
luau
local h, why = p:play_animation("world/avatars/anim/acenar", { looped = false })
if h then
h:set_speed(1.5)
h:stop()
end| Option | Meaning |
|---|---|
speed | −8 to 8. Zero pauses; negative plays backwards |
looped | default true |
weight | 0 – 1, how much this layer contributes when layers coexist |
mask | which joints it claims — see below |
blend | "override" (default) or "additive" |
fade | seconds of transition in and out. Default 0.12; 0 cuts dry |
mask defaults to "auto", which claims only the joints the clip actually animates — so an aiming pose made on the torso plays on top of walking and running without you having to author one clip per gait. "upper" and "lower" force a half; "all" takes the whole body, which is what an emote wants.
blend defaults to "override", which replaces — what "play this animation on that avatar" normally means. "additive" adds the clip's deviation from the base pose, and is only correct for a clip authored as a deviation: an aim, a lean, a limp.
Up to 8 animation layers per script. Needs this world's avatar animation permission.
The asset is named like every other one — see Naming an asset above. A name that is on neither shelf, or that is there and is not an animation, is refused on the line that wrote it and says which of the two it was.
It can be an animation you uploaded or one you made in the animator — they are the same kind of item here and behave identically. See Poses and animations.
set_locomotion
players:set_locomotion(id, clips) → true, or nil, reasonp:set_locomotion(clips) → the same
Swaps the clips somebody's walking draws, for as long as this script lives.
luau
p:set_locomotion({
walk = "world/avatars/anim/andar-armado",
run = "world/avatars/anim/correr-armado",
})
p:set_locomotion({}) -- give them their own backEach clip is named like every other asset — see Naming an asset.
The idle/walk/run/jump machine stays on the player's own machine — decided per frame, with no round trip. Only its content changes.
Pass the whole table each time: a gait you leave out means "their own animation".
| Key | |
|---|---|
idle walk run jump fall land | the basics |
walk_back walk_left walk_right | |
run_back run_left run_right | |
crouch_idle crouch_walk | the crouch |
The directional clips exist because the body follows the camera — you can walk sideways and backwards without turning. If one is missing, the base gait is drawn instead (walk_left absent falls back to walk), so ignoring them costs nothing.
The crouch is a second axis over the gait, not a gait of its own: a crouched player moving in any direction draws crouch_walk, because a duck-walk is a duck-walk sideways too and nobody wants to author four of them. Leave both out and the engine bends the knees itself — the body still comes down to the height everyone else shoots at, it just does not do it in your style.
Up to 16 locomotion overrides per script.
set_movement
players:set_movement(id, mods) → true, or nil, reason
How fast this one person moves, how high they jump, and whether they may jump at all. This is what a haste potion, a sprint, heavy armour, a stun or a gust of wind is made of.
luau
p:set_movement({ speed = 1.5, jump = 1.2 }) -- a haste potion
p:set_movement({ can_jump = false }) -- …and now the wind pins them down
p:clear_movement() -- back to the world's own legsEverything here is a multiplier on the world's own movement, never an absolute speed. A potion that says "you are 50 % faster" keeps meaning that after you retune the world; one that says "you walk at 4.8" silently becomes a nerf the day the base moves to 5. And multiplication is the only way to stack a haste, a sprint and heavy armour where the order you applied them in does not change the answer.
Every key is optional and every omitted one keeps its value, so the two lines above compose: the second one does not undo the haste.
| Key | Multiplies | Notes |
|---|---|---|
speed | walk, run and crouch | one gait with three throttle positions — boosting them apart makes a crouch outrun a run |
jump | jump strength | height goes with the square: 1.41 jumps twice as high, 2 jumps four times as high |
gravity | gravity | below 1 is the moon — higher and slower, where jump alone is higher and no slower |
accel | acceleration and braking | ice is 0.2; a dash that has to commit is 3 |
air_control | air control | still capped at full control: it is a fraction |
fly | flight speed | |
can_jump | — | false refuses the launch and nothing else |
can_move | — | false roots them: a stun, a cutscene, a channelled cast |
can_fly | — | false refuses flight, for a world that grants it to a class |
Multipliers are clamped to 0 – 10.
Why can_jump is a state and not something you cancel
You may be looking for a handler that sees the jump and refuses it — a preventDefault. That cannot be built honestly here.
The jump is decided on the player's own machine, one frame before their input reaches the region. That is what makes movement feel instant, and it is why nobody waits for a round trip to start walking. A refusal that had to travel to your script and back would arrive several trips after the body already left the ground — the player would see themselves jump and then get yanked down, on every jump.
So the refusal is state both machines already hold. Set can_jump = false when the wind picks up; clear it when it drops. From that moment both sides step the same body to the same place, and nobody rubber-bands.
can_move = false is a stun, not a freeze: they still fall, still ride a moving platform, and are still pushed by anything that pushes bodies. Being rooted is not being made of stone.
One set per player, not one per script. A second call replaces the first outright.
That is deliberate. An engine that added up contributions would have to decide what happens when your potion and your armour disagree, when one of them stops, and in what order they combine — and the right answer to all three is different in every game. Your combat system multiplies its own numbers and sends the product, which it can get right because it is the only thing that knows its own rules.
The buff dies with the script that granted it. Take the potion off, delete the object, re-save the script — and they go back to the world's own movement. A speed boost that outlived its potion would be somebody who never slows down again, in a world whose scripts have no record they were ever buffed. Nothing here follows anybody out of the world either.
Up to 16 buffed players per script. Needs the same avatar permission as the rest of this family.
clear_movement
players:clear_movement(id) → true, or nil, reason
Puts them back on the world's own movement, all at once.
movement
players:movement(id) → table, or nil
What this script has in force on them — not the world's own speeds.
luau
if not p:movement().can_jump then
print("the wind has them pinned")
endAn unbuffed player reads all 1s and all trues. That is a real answer, not a failure: nil means only that they are not in the region.
These travel as 32-bit numbers, so compare with a tolerance rather than == 1.4. A value that is not exactly representable comes back a hair off — the wire being honest, not a bug. 1.5, 1.25 and 2 are exact.
What somebody is wearing
worn
players:worn(id) → list, or nil
What they are wearing now.
| Answer | Meaning |
|---|---|
nil | they are not in this region |
| an empty list | they are here and wearing nothing |
Two different answers, deliberately.
Each item carries:
| Field | Meaning |
|---|---|
asset | the content id — what the creator knows it by |
slot | where it sits: "head", "top", "right_hand"… |
kind | "body" for a rigged piece, "attachment" for a rigid one |
visible | whether it is currently shown |
A hidden item still appears, with visible = false. Whether that counts is your decision.
HUDs never appear here: the server only sends somebody's HUD attachments to that person, and publishing them would leak something nobody else can see.
is_wearing
players:is_wearing(id, asset) → boolean, or nilp:is_wearing(asset) → the same
The item is named like every other asset — see Naming an asset — or given as the hash worn() hands back.
The shortcut for the common question. Counts visible items only.
luau
if players:is_wearing(p.id, MEMBER_BADGE) then
open_the_door()
endnil means they are not in this region — which is not the same as false.
What THIS WORLD puts on somebody
Three verbs, and one promise that covers all of them: nothing you put on a person is written to their wardrobe. It lives in this region, it dies with the visit, and they walk out as themselves. There is no restore step to write, and therefore none to forget.
Name what you put on them the way you name everything else — a path on this world's library:
luau
players:set_body(p.id, "world/corpos/mago")
players:dress(p.id, "world/roupas/tabarda", "top")A 64-character content hash still works, for scripts written before the library existed. Prefer the path: it can be read, it can be typed, and it keeps meaning the same garment after you re-upload the file behind it.
set_body
players:set_body(id, body) → true, or nil, why
The body they wear here. nil gives them their own back.
dress
players:dress(id, item, slot) → true, or nil, why
One garment, in one slot: top, bottom, outerwear, hair, head, eyes, hands, feet, accessory. nil as the item takes that slot off.
A slot you claim is yours — what they had there is not drawn, so the tabard goes over the shirt rather than through it. Dressing the same slot twice replaces it; it does not stack.
undress
players:undress(id) → true, or nil, why
Everything this world put on them comes off at once. Their own appearance was never gone.
When it refuses
| It says | It means |
|---|---|
nothing is published at '…' | that path is not on this world's library |
'…' is a sound — a body and a garment are MODELS | right path, wrong kind of thing |
'…' left this world's library | somebody removed it while your script ran |
permission | acting on somebody's avatar is the world owner's to delegate |
It refuses on the line that called it, not a tick later — a costume that silently never appears is indistinguishable from a script that never ran.
The whole world at once
An experience that wants everybody in the same body does not need a script for it. Its world settings have a body of this experience, worn from the instant of the join — before a line of script runs — and named the same way: world/corpos/heroi. Use these verbs for the decision about one person: the class they picked, the team they joined.
What somebody has saved
store
One person's memory inside this experience — their inventory, their progress, whatever your game gives them.
luau
local players, json = require("players"), require("json")
players:on_enter(function(p)
local raw, why = players.store:get(p.id, "save")
if why then return end -- not ready, or not allowed
local save = raw and json.decode(raw) or { gold = 0, items = {} }
...
end)It is the third store, and the three differ by who can see it:
| whose | who reads it | |
|---|---|---|
self.store | one object's | that object's scripts |
world.store | the region's | every script here |
players.store | one person's | this experience's scripts |
It follows the person. It lives on the central server, not in this world's save, so it is the same drawer whichever machine they land on the next time they play. That is the difference that matters: world.store travels with the world, this travels with the player.
Key it by player.id — the account's permanent id, which never changes.
The three verbs
luau
players.store:get(player, key) -- text | nil, reason
players.store:set(player, key, text | nil) -- true | nil, reason
players.store:update(player, key, fn) -- new value | nil, reasonupdate is the only correct way to change a value that depends on the old one:
luau
players.store:update(p.id, "gold", function(current)
return tostring((tonumber(current) or 0) + 10)
end)Only while they are here
luau
local v, why = players.store:get(someone_who_left, "gold")
-- nil, "that player is not here"Not a quirk. This world holds the custody of somebody's save only while they are inside it — the moment they walk into another world, that world takes over, and a write from here would be refused anyway. Saying so at the call is better than letting you write data that silently never lands.
nil, reason and nil, nil are different answers. nil, nil means nothing was saved under that key — a new player. nil, <reason> means the drawer was not reachable. Treating the second as "new player" is how a save gets overwritten with a fresh one, so check why before deciding somebody is new.
Who may
Both reading and writing need the world's datapermission. Unlike the world's scoreboard — which is public because it is already on the wall — what one person has in their inventory is theirs, and a visitor's script has no business reading it.
How much fits
| keys | value size | total per player |
|---|---|---|
| 128 | 64 KB | 256 KB |
A save per subject — inventory, progress, settings — not a key per item. 256 KB is on the order of 2500 inventory lines. Values are text: use json to keep a table in one.
See also
- json — putting a table into a store
- Saving data — the recipe, and the other two stores
- Player and screen — HUDs, crosshair, camera, weapons
- self:on_action — reacting to a key press
- Permissions — what a world may ask of its visitors
