Skip to content

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 call

Use 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 writeWhere it is looked up
"world/avatars/anim/dancar"this world's library — the shelf
"dancar"this object's own contents
a 64-character hashthe 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)
end

radius 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

FieldMeaning
idthe account's permanent id. Never changes. Use this as a key
nametheir display username. They can change it — never use it as a key
numtheir public number. 0 means an account without one

What their body is doing

FieldMeaning
gait"idle", "walk", "run", "jump" or "fall"
speedspeed on the ground, m/s, without the vertical part
groundedare their feet on something?
crouchedare they crouched?
turnhow fast they are turning, degrees per second. Positive is to the left
holdingworld 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

FieldMeaning
poswhere the player believes they are — the one you want
seenwhat the region actually saw, raw
velvelocity, m/s
lagone-way delay, in seconds, measured by the connection
lookwhere 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
OptionMeaning
speed−8 to 8. Zero pauses; negative plays backwards
loopeddefault true
weight0 – 1, how much this layer contributes when layers coexist
maskwhich joints it claims — see below
blend"override" (default) or "additive"
fadeseconds 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 back

Each 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 landthe basics
walk_back walk_left walk_right
run_back run_left run_right
crouch_idle crouch_walkthe 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 legs

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

KeyMultipliesNotes
speedwalk, run and crouchone gait with three throttle positions — boosting them apart makes a crouch outrun a run
jumpjump strengthheight goes with the square: 1.41 jumps twice as high, 2 jumps four times as high
gravitygravitybelow 1 is the moon — higher and slower, where jump alone is higher and no slower
accelacceleration and brakingice is 0.2; a dash that has to commit is 3
air_controlair controlstill capped at full control: it is a fraction
flyflight speed
can_jumpfalse refuses the launch and nothing else
can_movefalse roots them: a stun, a cutscene, a channelled cast
can_flyfalse 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")
end

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

AnswerMeaning
nilthey are not in this region
an empty listthey are here and wearing nothing

Two different answers, deliberately.

Each item carries:

FieldMeaning
assetthe content id — what the creator knows it by
slotwhere it sits: "head", "top", "right_hand"
kind"body" for a rigged piece, "attachment" for a rigid one
visiblewhether 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()
end

nil 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 saysIt means
nothing is published at '…'that path is not on this world's library
'…' is a sound — a body and a garment are MODELSright path, wrong kind of thing
'…' left this world's librarysomebody removed it while your script ran
permissionacting 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:

whosewho reads it
self.storeone object'sthat object's scripts
world.storethe region'severy script here
players.storeone person'sthis 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, reason

update 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

keysvalue sizetotal per player
12864 KB256 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

Hungrit scripting documentation.