Appearance
Player and screen
Everything a script draws on somebody's screen, or changes about their view.
These verbs live on a player, not on self:
luau
self:on_touch(function(p)
p:show_crosshair() -- p is a player
end)
local who = players:get(id)
if who then
who:set_camera({ distance = 0 })
endA personal script names nobody. A HUD or a worn item already knows whose screen it draws on — its wearer's — so it calls self:show_crosshair{...} with no player. A world object must say who it is drawing on.
Called on somebody else from a personal script, these verbs exist and answer nil, reason rather than disappearing. The rule stays the same; what changed is that it explains itself.
The player table
Whether it came from a handler or from players:get, it carries who they are and what their body is doing — see players for every field. Everything below is on top of that.
Menus
show_hud
p:show_hud(spec) → true, or nil, reason
Shows a menu, panel or prompt on this player's screen. Showing one with an id that is already on screen replaces it.
luau
p:show_hud({
id = "shop",
anchor = "center",
modal = true,
vars = { coins = 40 },
pages = {
main = {
{ "text", "General store", size = 22, bold = true },
{ "text", "Coins: {coins}" },
{ "button", "Buy rope", emit = "buy", data = { item = "rope" } },
{ "button", "Close", close = true },
},
},
})The whole table format — every node, every style key, every option — is in the HUD reference.
The menu runs on the player's own machine. Changing pages, moving a slider, ticking a box: instant, with no round trip. Only a button carrying emit comes back to your script, at self:on_hud.
update_hud
p:update_hud(id, vars) → true, or nil, reason
Pushes new values into a menu that is already on screen, without rebuilding it.
luau
p:update_hud("shop", { coins = 30 })Merges by key — keys you do not mention keep their values. The screen reflects it on the next frame.
This is how a live number gets onto a HUD: health, score, ammunition, a timer. Rebuilding the whole HUD instead would throw away which page they were on.
hide_hud
p:hide_hud(id) → true, or nil, reason
Takes it off their screen.
The pointer
show_cursor
p:show_cursor() → true, or nil, reason
Reveals the mouse pointer and freezes their avatar. This is what opens a shop, an inventory or anything you click — usually right after a show_hud.
luau
p:show_hud(SHOP)
p:show_cursor()It is a request, not an order. The player owns their own pointer. A script cannot close their pause menu, cannot re-capture a cursor the person revealed themselves, and cannot reach edit mode. Esc always closes what the script opened and returns to the game.
Only works in an experience world, where entering is already consent. In a social world it answers nil, "permission".
hide_cursor
p:hide_cursor() → true, or nil, reason
Captures the pointer back. Undoes this script's show_cursor, and has no effect if the player was the one who revealed it.
The crosshair
show_crosshair
p:show_crosshair() → true, or nil, reasonp:show_crosshair(spec) → true, or nil, reason
Turns on a crosshair at the centre of their screen. With no table you already get a usable one.
luau
p:show_crosshair({
length = 7,
thickness = 2,
gap = 5,
outline = 1,
dynamic = true,
color = { 90, 255, 140 },
})| Key | Meaning |
|---|---|
length | length of each arm, in pixels. 0 = no arms — use with dot |
thickness | thickness of the arms and the dot |
gap | distance from the centre to where each arm starts |
outline | black outline width |
dot | half-size of the centre dot. 0 = no dot |
t_style | true removes the top arm, so the crosshair does not cover what you are about to hit |
dynamic | arms open while the player moves, and by exactly the weapon's shot cone |
color | {r, g, b} or {r, g, b, a}, 0–255 |
Keep outline above zero. A green crosshair on grass disappears without it, and that is the first complaint about every crosshair ever made.
Calling it again swaps the crosshair rather than stacking another one.
It is drawn on the player's machine, per frame, so aiming never waits for the region. It hides itself while the cursor is on screen — two crosshairs is one more than useful.
dynamic = true makes the picture unable to lie about the bullet: the arms open by the same cone that deviates the shot.
hide_crosshair
p:hide_crosshair() → true, or nil, reason
Removes it. It also leaves on its own when the player changes worlds.
The camera
Camera presets
Three places take the same table, and every field is optional — a preset only touches what it names, so { distance = 1.2 } is already complete.
| Key | Meaning |
|---|---|
distance | from the pivot to the eye, in metres |
height | height of the pivot above the feet, in metres |
shoulder | sideways offset, in metres — over-the-shoulder framing |
fov | field of view in degrees, while it applies |
smooth | half-life of the transition, in seconds. ~0.05 snappy, ~0.12 weighty, ~0.4 cinematic. Default 0.12 |
distance = 0 is first person. There is no separate flag, on purpose: how far the eye sits from the body is already a number, and a boolean beside it would be a second owner of one fact. It also means you can ease between first and third person on a single number.
smooth, never ease: in this API ease is always the name of a curve and smooth is always a number of seconds.
set_camera
p:set_camera(preset) → true, or nil, reason
The framing this player keeps, until something takes it away.
luau
p:set_camera({ distance = 0, height = 1.62 }) -- first person
p:set_camera({ distance = 2.4, shoulder = 0.4 }) -- over the shoulderDifferent from the camera on an action, and both exist:
| when | cost | |
|---|---|---|
set_camera | the standing framing | a round trip — set it when somebody joins or picks something up |
action camera | while a button is held | none — it travels with the action and moves on the frame of the click |
A held action outranks this while it is down.
The player must have granted this world their camera permission. Without it the client refuses silently, because moving somebody's eye is theirs to allow.
It is a latch, and it is released for you. If the script that set it dies — the item comes off, the object is deleted, the script is re-saved — the engine gives the player their own camera back. Otherwise somebody would be stuck inside their own head with nothing left able to let them out.
reset_camera
p:reset_camera() → true, or nil, reason
Gives them their own camera back.
The first-person body
set_viewmodel
p:set_viewmodel(spec) → true, or nil, reason
The arms this player sees when they look through their own eyes, holding whatever is in their hand.
luau
who:set_viewmodel({
model = "world/bracos", -- or a name in this object's contents
pos = { x = 0, y = -0.15, z = -0.35 },
rot = { x = 0, y = 90, z = 0 },
scale = 0.01,
})| Field | ||
|---|---|---|
model | required | the model, by name: something in this object's contents, or a path in the world's library (world/…) |
pos | {x,y,z} metres | where it sits relative to the eye — -z is forward |
rot | {x,y,z} degrees | same convention as self:hold |
scale | number | uniform. A model authored in centimetres wants 0.01 |
Named, never hashed. A script never sees an asset id — it names something, exactly as world:spawn{ model = … } and every sound verb do. A slash means the world's library and nothing else; a bare name means this object's own contents. An unknown name is refused on the line that wrote it, with a sentence naming both shelves.
The model needs no skeleton and no animation. A static mesh, already modelled in a holding pose, is the common case and the cheapest one. The body is anchored by its hand — placed so the hand lands on the same socket the weapon is drawn from — so there is no bone to solve, and arms and weapon become one piece. Everything that already moves the weapon (the turn sway, the walk bob, the recoil, aiming down the sights) moves the arms for free.
But if it has one, the skeleton is kept — and then the actions exported in the same file can be played with play_viewmodel_animation. That is how a reload, a bolt pull or a draw gets on screen. Both shapes are legitimate and both are supported: a pair of arms holding a rifle needs no bone; a reload does not exist without one.
One verb covers what looks like three systems, because it is per player:
| What you want | Where you call it |
|---|---|
| One pair of arms for the whole world | players:on_enter |
| Different arms per weapon | each weapon's on_hold |
| Gloves that change with the outfit | the outfit's script |
pos / rot / scale are read in the eye's own basis — +x right, +y up, -z forward — exactly like self:hold. Not a coincidence: both answer where does this sit relative to my eye. The dev Pegada window tunes both with the same buttons and prints both as a block you can paste.
No camera permission is needed. This draws on the screen of somebody who already chose to be here, like a HUD or a crosshair — it never moves an eye and never touches the body other players see.
It is a latch, and it is released for you. If the script that set it dies — the item comes off, the object is deleted, the script is re-saved — the arms go with it. A pair of arms outliving its script would sit on somebody's screen with nothing left able to remove them.
Only while something is in a hand. Arms modelled gripping a rifle, drawn while the player holds nothing, read as a bug.
clear_viewmodel
p:clear_viewmodel() → true, or nil, reason
Takes the first-person body away.
play_viewmodel_animation
p:play_viewmodel_animation(clip, opts?) → true, or nil, reason
The gesture those arms make: a reload, a bolt pull, a draw.
luau
self:on_action("reload", function(p)
local who = players:get(p.id)
if not who then return end
who:play_viewmodel_animation("reload")
end)| Field | ||
|---|---|---|
clip | string | which clip, and from where — see the two sources below |
looped | boolean | repeat forever. Default false |
speed | number | -8…8, never 0. Negative plays backwards — that is how "put the weapon away" is written without exporting a second clip |
fade | seconds | how long the change of pose takes. Default 0.12, max 1 |
The clip comes from one of two places
An animation you name. The same two shelves every other verb reads: a slash means the world's library, a bare name means this object's contents.
luau
who:play_viewmodel_animation("world/avatars/Pose-dobrou")This is where the animator saves — and if you rigged your arms with the auto-rig, it is your only source, because the auto-rig writes geometry, skeleton and materials into its GLB and no animation at all.
A pose authored on a whole body drops straight onto an arms cut: an animator document stores deltas from the rest pose, and the arms cut carries the same skeleton, the same names, in the same order. Nothing is retargeted.
An action inside the model's own file. A bought FPS arms GLB bundles its own (idle, reload, fire) — which is how Blender and Mixamo export.
luau
who:play_viewmodel_animation("reload")The rule is one sentence: a name is looked up on the two shelves, and what is on neither is an action inside the model.
| You write | It means |
|---|---|
"world/anims/reload" | the library. Not published, or not an animation → refused on that line |
"reload" | this object's contents if an animation is there; otherwise an action inside the model |
"#reload" | the action inside the model, always — use it when a content item shares the name |
"world/pack#sacar" | one action inside a named clip pack |
| nothing | the single clip of the model's own file |
Prefer the name over the index. Re-exporting from Blender reorders the actions without warning; a name survives.
A gesture that does not repeat comes home by itself. When it ends, the arms return to the model's rest pose — and what that pose is depends on where the model came from:
- A bought FPS arms GLB was modelled gripping a weapon, so its rest already is the holding pose. A reload ends holding the weapon and you write nothing.
- An auto-rig arms cut rests in the pose the body was rigged in — an A-pose, arms out to the sides. That is the pose it will return to, which is not what you want on screen.
So with an auto-rig body, play a looped idle once and let it define the holding pose; a one-shot gesture on top of it comes home to that instead:
luau
who:play_viewmodel_animation("world/avatars/Segurando", { looped = true })The weapon follows the hand. Once the arms have a skeleton, whatever they are holding hangs off the hand bone: a reload that pulls the hand down takes the gun with it, and so does moving, turning or rescaling the arms themselves. That makes fp a grip in the hand — tune the arms first, the weapon second. Zero rotation means aligned with the arms, whatever twist the rigger left in that bone. Two hands holding two things each follow their own.
Refusals name the cause, because the ways this can quietly do nothing all look identical on screen — arms standing still:
| What you see | What it says |
|---|---|
| the model has no skeleton | export the GLB with its armature and skin |
| no action by that name | it lists the ones the file has |
| the file has several and you named none | it lists them |
| the clip drives no bone of this skeleton | it was authored against another rig |
The model must be there first. Setting the arms and playing a gesture on the same line is the normal thing to do, and the model has a whole download between them — the gesture is held and starts the moment the arms arrive.
Uploading a rigged model. A skinned mesh with no hips is taken as a rigged prop and stored with its skeleton intact. A skinned mesh with hips is an avatar and is still validated as one — which is what an auto-rig arms cut is, since the cut keeps the whole skeleton and shows only the arms.
Auto-rig models are written in metres, so their scale in set_viewmodel is around 1, not the 0.01 a centimetre export wants.
No camera permission is needed, for the same reason as set_viewmodel. It needs no latch of its own either: the gesture lives on the viewmodel's skeleton, and the viewmodel is already given back when the script that set it dies.
stop_viewmodel_animation
p:stop_viewmodel_animation() → true, or nil, reason
Returns the arms to the model's rest pose. The transition out is the same length the gesture came in with — one number describes how abrupt this pair of arms is, and a second one would eventually disagree with it.
set_viewmodel_motion
p:set_viewmodel_motion(motion) → true, or nil, reason
How much the weapon on this player's screen swings by itself: the rise and fall of walking, the way it trails behind a turn, the dip while firing.
lua
-- a heavy machine gun: swings further, and slower
who:set_viewmodel_motion({ bob = 1.6, bob_rate = 0.8, sway = 2.0 })
-- a weapon on a tripod: nothing on screen moves but the aim
who:set_viewmodel_motion({ bob = 0, sway = 0 })| Field | What it does |
|---|---|
bob | how far the weapon rises and falls as you walk |
bob_rate | how fast it does that — per METRE walked, not per second |
sway | how far it trails behind when you turn: the weight of it |
kick | how far the muzzle dips while firing |
They are multipliers, not measurements. 1 is the engine's own number, 0 is off, 2 is twice as much, up to 8. The engine's numbers are a walking person's stride measured against a real world — 4 mm of rise at 0.28 cycles per metre — and writing that in metres would mean knowing it. If you want an absolute value, multiply.
Everything you do not name stays as it is, so { bob = 0 } is a complete call and changes exactly one thing. 0 is a value and not silence: turning the swing off entirely is a normal request — a mounted gun, a camera, a vehicle.
It moves the arms too. The first-person body and the weapon ride the same socket, so one setting moves both. A world that never calls this gets the engine's own swing, which is what every world had before this verb existed.
This is not recoil. The kick that moves your aim is set_weapon{ recoil = … }, and the two are separate on purpose: one is something the player fights back against, the other is something they only see.
No camera permission, for the same reason as set_viewmodel. It is a latch, released for you when the script that set it dies — a player left with a rocket launcher's swing and no rocket launcher would have no way to undo it.
reset_viewmodel_motion
p:reset_viewmodel_motion() → true, or nil, reason
Gives this player the engine's own swing back.
Weapons
set_weapon
p:set_weapon(action, spec) → true, or nil, reason
Describes the weapon this player is now holding, for a named action.
luau
self:on_hold(function(p)
local who = players:get(p.id)
if not who then return end
who:set_weapon("fire", {
recoil = { up = 1.05, side = 0.55, climb = 0.11,
max = 12, recover = 0.16, reset = 0.4 },
auto = 0.1,
cycle = 0.1,
spread = { base = 0.18, moving = 4.2, air = 10, crouch = 0.72 },
})
end)Use this rather than recoil = on the action, for anything a player picks up. An action belongs to the world: "fire" is one bit of the region, and two objects declaring it are one trigger — which is right, because a player has one finger. So a recoil carried on the binding is a recoil everybody shares: a rifle and a sidearm fold into whichever the table saw last, every weapon fires at one speed, and somebody holding nothing still gets kicked when they click.
A weapon belongs to whoever picked it up. Arm them on on_hold, disarm them on on_drop — the same lifetime the crosshair has, and for the same reason.
| Key | Meaning |
|---|---|
recoil | how firing kicks the view. true = a default rifle |
auto | seconds between shots while the trigger is held. Omitted = one shot per press |
cycle | the shortest time between two shots however the trigger is worked |
spread | the shot cone. true = a default rifle cone |
It costs a round trip, which is free: picking a weapon up is not a per-frame event, and the kick still lands on the frame of the shot because by then the player's machine already holds the description.
Released for you if the script dies, like every other latch here.
auto and cycle
auto only ever governed a held trigger. Without cycle, a semi-automatic fires as fast as a mouse can be clicked, and a weapon declaring ten rounds a second gives fifteen to anyone who clicks quickly — which makes clicking better than holding, the exact opposite of what a recoil pattern exists to reward.
Leave cycle out on an automatic: auto stands in, because an automatic's cycle is its rate of fire, and typing the same number twice is how the two end up different. Set it on anything semi-automatic — cycle = 0.15 is a brisk sidearm.
Both are clamped to 0.05 – 5 seconds. Both machines enforce it: the region discards an early press, and the player's machine declines to step the recoil pattern for a bullet that will not leave.
recoil
How firing moves the view. Every field is optional; the defaults are already a shootable rifle.
| Key | Meaning |
|---|---|
up | degrees the view rises on the first shot of a burst |
side | degrees of sideways wander at full amplitude. 0 climbs only |
climb | extra degrees of rise per consecutive shot |
max | shot number past which the kick stops growing |
recover | half-life, in seconds, of the view returning to where the mouse points |
reset | seconds without firing before the pattern restarts from shot one |
The kick moves the real aim, not an overlay. The direction that reaches your handler already has it, so the bullet goes where the recoil put it and pulling the mouse down really does bring it back. It is applied on the player's own machine, on the frame of the shot — a kick sent from the region would land a round trip after the bullet that caused it.
The sideways wander is a fixed pattern indexed by shot number, not random. Shot ten lands in the same place every time, so a player can learn it. That is the difference between a skill and a dice roll.
max matters: without a ceiling, a long burst eventually points at the sky, which is not difficulty, it is an unusable weapon.
reset is what makes tapping accurate and spraying not.
spread
How wrong the bullets are allowed to be — the shot cone, in degrees.
| Key | Meaning |
|---|---|
base | the cone standing still on the ground, first shot |
moving | added at full running speed |
air | added while airborne |
crouch | multiplies the whole cone while crouched |
per_shot | added by every shot fired |
recover | half-life, in seconds, of that accumulation |
move_min | fraction of full speed that is free |
max | the cone stops growing here |
Recoil moves the aim; spread moves the bullet. You can fight a recoil; you cannot fight a cone.
move_min is the most important number here. It is the dead zone: below that fraction of full speed, moving costs nothing at all. It is what makes stopping a skill — without it there is no such thing as having stopped enough, so nobody ever lets go of a movement key and the game becomes a running duel.
The cone is applied by the region, before your handler runs: by the time on_action fires, shot.dir already is the bullet, so your world:raycast inherits the whole model without changing a line.
The player's crosshair opens by exactly this cone when dynamic = true, so the picture cannot lie about the bullet. That is also why the cone is enforced by the region and the recoil is not: a player who compensates a kick perfectly earned it, but a machine that ignored its own cone would simply never miss.
Leave spread out entirely and the weapon shoots exactly where it points, forever.
clear_weapon
p:clear_weapon(action) → true, or nil, reasonp:clear_weapon() → disarms every action
Disarm on on_drop. The no-argument form is what a death or a round reset wants.
See also
- HUD reference — every node and style key
- self:on_action — declaring the key
- Weapons and combat — a complete weapon
