Appearance
world
world is the region your object stands in — everything that is not the object itself.
luau
local world = require("world")Unlike self, this one must be required. There is no world global.
Everything on this page
Making things · spawn · tween · move · despawn
Asking about the world · raycast
Time · clock · on_start · on_wake
Sky · weather · set_weather
Sound · play_sound
Shared memory · store
Sub-tables · world.terrain · world.blocks · world.build · world.loading
Making things
spawn
world:spawn(opts) → new object id, or nil, reason
The verb that makes matter, and the only one. The id comes back immediately, so the next line can already move it, remove it or hand it over.
luau
world:spawn({ shape = "sphere", pos = here }) -- a built-in shape
world:spawn({ model = "arrow", pos = here }) -- from my Contents
world:spawn({ model = "world/meshes/crate" }) -- from the world library
world:spawn({ copy = true, pos = here }) -- another one of meIt answers two questions, and that is the whole API.
1. What is it made of?
Name exactly one of these. None at all gives you a box.
| Key | What you get |
|---|---|
shape | a built-in shape: "box" (default), "sphere", "cylinder", "cone", "prism", "pyramid", "torus", "tube", "ring", "arch", "lbeam" (an L), "channel" (a U you can walk down) |
model | a model somebody made — a name from this object's Contents, or a path on the world's library (world/meshes/crate) |
copy | true — another one of the calling object: same shape, same properties, same children, same scripts, fresh memory |
copy is the one that makes "everybody gets one" possible, because it is the only body that arrives with behaviour — no script can hand its own source to a bare model. Copy the root of a group, never a piece of one; the whole subtree comes across together, up to 64 pieces, children keeping their places.
model is what stops every dropped coin and shell casing from being a coloured cube. The script only ever says the name — no content hash ever enters a script, which keeps one way of addressing what an object holds instead of two.
The slash decides which shelf. A bare name lives inside the object and travels with it; a world/… name lives in the world you are standing in.
2. Where does it go?
| Key | Type | Meaning |
|---|---|---|
pos | point | a place in the world. Defaults to wherever the calling object is |
hold | string | straight into a player's hand — their id |
at | string | which socket, when hold is given. "right_hand" by default |
grip | table | how it sits on that socket: pos, rot, scale |
pos and grip.pos are different things. pos is where it is born in the world; grip.pos is where it sits on the bone. Handing a purchase over with the buyer's world coordinates in grip puts the object sixty metres from the hand that bought it.
hold answers to the same rule self:hold does: the player granted this world its hold permission at the door, and has interacted with the object doing the handing. The new thing has no history of its own — nobody can have touched what did not exist.
Everything else
The same table whichever body you named.
| Key | Type | Meaning |
|---|---|---|
scale | number or point | a number is uniform. A shape with no size is half a metre; a model with no size is the size its author built it |
rot | point | orientation in degrees |
facing | point | lay it flat against a direction — see below |
to | point | where it is going — makes this a ballistic spawn |
seconds | number | how long the flight takes. Required with to |
ease | string | curve of the flight: "linear" (default), "in", "out", "in_out" |
life | number | seconds before it disappears. Default 30, maximum 300. 0 = as long as the region runs |
name | string | display name |
color | string or table | "#rrggbb" or { r, g, b } |
transparency | number | |
metallic | number | |
roughness | number | |
emissive | number | |
collide | boolean | false makes it pass-through — what a purely visual effect wants |
facing — marking a wall
facing turns the object's own up-axis to point along a vector. Hand it a surface normal and it lands parallel to that surface, whatever the surface is: floor, wall, slope, imported mesh.
luau
local hit = world:raycast({ from = shot.eye, dir = shot.dir, max = 80 })
if hit then
world:spawn({
shape = "cylinder",
pos = hit.pos + hit.normal * 0.006, -- just off the surface
facing = hit.normal,
rot = { x = 0, y = math.random(0, 359), z = 0 },
scale = { x = 0.035, y = 0.01, z = 0.035 },
color = "#12100e",
collide = false,
life = 25,
})
endWithout it you would be turning a normal into three angles by hand, which is a page of trigonometry with two cases that break.
rot still applies, composed afterwards, so it reads as a spin in the object's own frame — which is what stops thirty marks from being thirty identical stamps.
Push it off the surface by a hair. Flush against it, the two fight for the same pixels and flicker.
to — stating a whole flight at once
Giving to and seconds makes the spawn ballistic: the entire path is stated once, at birth, instead of being animated afterwards.
luau
world:spawn({
model = "arrow",
pos = bow_tip,
to = target,
seconds = 0.4,
})Use it for anything whose path you already know when you create it. Two things change, and neither is something a script can fix afterwards:
- The path goes out in one message instead of thirty a second.
- Because it is a plan rather than a stream of poses, a player on a slow connection sees the shot already in progress when it reaches them, instead of starting at the muzzle a round trip late.
A ballistic spawn dates itself: leave life out and it lives just past the end of its own flight.
Everything a script spawns is temporary
Never saved with the world, gone after a restart, and expiring on its life timer. That is the design, not a limitation — permanence is what the build tools are for, and the guarantee that the region can always clean up is what makes it safe to create objects from a timer.
life = 0 means for as long as this region runs, which is what a weapon somebody just bought needs and what a bullet hole does not.
Refusals
| Reason | Meaning |
|---|---|
"permission" | in a social world, only the world owner's scripts may spawn. In an experience, any script may |
"too many commands" | more than 8 effects in one event |
"too many spawned objects" | the region already has 256 script-made objects alive |
The new object belongs to the owner of the script that made it.
tween
world:tween(id, opts) → true, or nil, reason
Moves something you spawned, smoothly, over seconds.
luau
local id = world:spawn({ shape = "box", pos = start })
world:tween(id, { to = finish, seconds = 3, ease = "in_out" })Takes to, rot, scale, seconds, ease — the same words as self:tween. There is no completion handle here; use self:after for "when it lands".
self:tween moves the object the script is on. Used on a turret it sends the turret flying at the player while its projectiles sit at the muzzle. self: is me; world: with an id is the thing I made.
For a projectile, prefer world:spawn{ to =, seconds = } — a path already known at spawn time costs one message that way and about fifty this way. Use world:tween for what you decide later: a platform that changes course, a door somebody opened.
move
world:move(id, x, y, z) → true, or nil, reason
The instant counterpart of world:tween. Same rule about which object it addresses.
despawn
world:despawn(id) → true, or nil, reason
Removes something you spawned, before its life runs out.
Only ids that came from your own world:spawn. An object id from the build tools is refused with a reason, and another script's spawn is left alone. Despawning something that already expired is not an error.
luau
-- a pool: the oldest mark disappears when the newest is born
table.insert(marks, id)
while #marks > 16 do
local old = table.remove(marks, 1)
world:despawn(old)
endWorth doing. The region allows 256 script-made objects in total, so an object that spawns without a ceiling eats the budget everybody else shares.
Asking about the world
raycast
world:raycast(opts) → hit, or nil, or nil, reason
Fires an imaginary line and reports the nearest thing it meets — objects, players and the ground, whichever comes first.
luau
local hit = world:raycast({
from = shot.eye,
dir = shot.dir,
max = 80,
ignore_player = player.id,
})
if hit and hit.what == "player" then
print("hit " .. hit.name .. " in the " .. hit.zone)
endThree answers, and they mean different things:
| Answer | Meaning |
|---|---|
| a hit | it met something |
nil | clear line — it met nothing within reach. Not an error |
nil, why | the call was wrong: no from, no aim, a zero direction. Fix the code |
Options
| Key | Type | Meaning |
|---|---|---|
from | point | required — where the ray starts |
to | point | aim at this point. The ray stops there |
dir | point | aim along this direction. Need not be unit length |
max | number | reach in metres. Defaults to the distance to to, or to 512 with dir. Always capped at 512 |
players | boolean | let it hit player capsules. Default true |
ignore | number or list | object ids the ray passes through. Your own object is always ignored |
ignore_player | string or list | player ids the ray passes through |
Aim with either to or dir, never both.
ignore_player matters more than it looks. A shot fired from eye height starts inside the shooter's own capsule, so without it every weapon in every world hits its owner at point-blank range.
What comes back
| Field | Meaning |
|---|---|
what | "object", "player" or "ground" |
object | the object id — only when what == "object" |
player | the player id — only when what == "player" |
name | their display name — only when what == "player" |
zone | where on them: "head", "body", "legs" |
pos | the contact point, in world space |
normal | the surface direction at the contact, always facing back toward from |
distance | metres from from to the contact |
The hit zones come from the player capsule, which is the same size for everybody — not from the avatar somebody uploaded. A dragon and a gnome are the same target, because for gameplay they are, and because otherwise the first thing to happen would be somebody uploading a tiny avatar to be hard to hit.
No damage number comes with it. What a head is worth is your game's decision.
raycast is an immediate read: it costs nothing from your effect budget, needs no permission, and answers for the world as it is right now.
Time
clock
world:clock() → seconds
How many seconds this world has lived. Not how long the server has been up, and not the date — a plain growing number you compare against one you saved earlier.
luau
self.store:set("planted", tostring(world:clock()))
-- much later, possibly after a restart
local planted = tonumber(self.store:get("planted") or "0") or 0
if world:clock() - planted >= 3600 then
ripen()
endIt keeps running while the world is off. A world closed on Friday and opened on Monday comes back with the whole weekend on its clock, because that is the only version of time a visitor recognises: the crop grew, the ore came back, the shop restocked.
It never runs backwards. If the machine's own clock is corrected into the past, this one pauses instead of rewinding, so now - before is never negative and you never have to defend against it.
Save the timestamp, not the countdown. A countdown only moves while somebody is ticking it; a timestamp is true whether anyone was there or not.
Free, and needs no permission.
on_start
world:on_start(fn) → handle
Runs once, when the world has finished coming up. This is where a world sets itself up: the weather it opens in, the props it wants standing, the round it wants running.
luau
world:on_start(function()
world:set_weather({ rain = 0 })
end)Your script's body already runs at start. What the body cannot promise is anything about the world around it — the region brings its scripts up one object at a time, so while your body runs, most of the others do not exist yet.
on_start runs after the last script of the region has loaded. So the body is for declaring — handlers, timers, constants — and on_start is for acting.
Attach it to a world that is already running and it fires on the next tick: "start" means the earliest moment this script can trust the world around it, and for a script you just saved that moment is now.
on_wake
world:on_wake(fn) → handle
Runs when the world resumes after time nobody simulated — a restart, or a quiet region that had gone to sleep. elapsed is how many seconds of world time went by unsimulated.
luau
world:on_wake(function(elapsed)
local stages = math.floor(elapsed / 60) -- one stage a minute
advance_crops(stages)
end)A region with nobody in it stops ticking: your timers do not fire and your handlers do not run. This is the event that lets you catch up in one step instead of pretending the time never happened.
Catch up with arithmetic, not with a loop. elapsed can be a month — over two and a half million. for i = 1, elapsed do will burn your instruction budget and the script will be stopped, exactly like any other runaway loop.
A repeating on_timer beats once on waking, not once per period the sleep went through — a timer is a beat, not a debt. If you need the count of what was missed, it is elapsed divided by your period.
It runs once, and it runs after on_start. A world being born has nothing to catch up on, so it does not fire at all; neither does it fire for a script you save into a world that is already running, since that script lost no time.
Sky
weather
world:weather() → table
What the sky is doing right now. Never fails and needs no permission — everyone standing here is already looking at it.
| Field | Meaning |
|---|---|
rain | 0 = clear .. 1 = downpour |
snow | 0 = none .. 1 = heavy |
lightning | 0 = never .. 1 = every few seconds |
wind | horizontal wind speed, m/s |
wind_dir | wind heading in degrees: 0 points at +X, growing toward +Z |
A clear sky is rain = 0, not a missing answer.
set_weather
world:set_weather(opts) → true, or nil, reason
Sets the region's weather, for everyone standing in it. Every field is optional and every omitted one keeps its current value, so world:set_weather{ rain = 0.8 } is the whole API for most worlds.
| Key | Range | Meaning |
|---|---|---|
rain | 0 – 1 | how hard it is raining |
snow | 0 – 1 | how hard it is snowing |
lightning | 0 – 1 | how often it strikes |
wind | 0 – 40 | m/s |
wind_dir | degrees | wraps, so 370 is a legible way to write 10 |
fade | seconds | cross-fade into this. 0 (default) snaps |
sound | table | the recordings this storm is made of — see below |
Only rain and snow really matter: how grey the sky goes, how far you can see, how thick the cloud is, how wet the ground looks and how much snow is lying on it are all derived from those two — so two worlds asking for the same downpour get the same downpour.
They are independent, so both above zero is sleet. Snow is not rain with a different sprite: it brightens the air instead of darkening it, it lies on up-facing surfaces, and what it leaves behind takes minutes to melt once the sky clears.
The snow dial is really a visibility dial — roughly how far you can see:
snow | you can see about |
|---|---|
| 0.2 | 1.5 km — decorative |
| 0.4 | 190 m |
| 0.6 | 55 m |
| 0.8 | 23 m |
| 1.0 | 12 m — a whiteout with no horizon |
lightning is a frequency, not a trigger: you say how stormy the sky is and each machine picks its own bolts, so a flash costs nothing on the wire. To fire one at a dramatic moment, raise it and lower it again — you get a strike within a second or two, not on an exact frame. Every strike lights the world through windows and casts shadows, and its thunder arrives late by the real distance.
wind tilts the falling rain, drifts the clouds, and pushes anything with dynamic = true, in proportion to its drag and inversely to its mass. It is self-limiting: an object accelerates until it is moving with the air and no further, and a settled object the air cannot actually slide is left asleep.
Weather belongs to the place, not to your script. It survives the script being re-saved, the object being deleted, the region running for a week, and the region restarting — exactly like the ground you sculpted. The way back to sunshine is world:set_weather{ rain = 0 }, and a world that should always open the same way says so from on_start.
Needs the weather permission: in a social world only the world owner's scripts change the sky; in an experience, any script may.
The storm's voice
By default the engine synthesises the rain and the thunder. That is not a placeholder: rain is filtered noise, a rain loop is the one ambience a listener spots immediately (because a loop repeats and rain never does), and synthesis costs no download and bends continuously with how hard it is falling and whether you are under a roof.
But it cannot be your storm. sound is where you name your own.
luau
world:set_weather({
rain = 0.8,
lightning = 0.6,
sound = {
rain = "world/sons/chuva",
wind = "world/sons/vento",
thunder = {
{ sound = "world/sons/trovao-longe", strength = 0.15 },
{ sound = "world/sons/trovao-perto", strength = 0.9 },
},
},
})| Key | What it is |
|---|---|
rain | the rain bed — looped, its level following rain |
snow | the snow bed |
wind | the wind bed — its level follows wind, so it is silent in still air |
thunder | one or more claps, chosen by how hard the bolt hit |
Per layer. Naming a rain replaces the synthesised rain and leaves the synthesised thunder alone, so "my own rain, the house thunder" is one line.
Omitted keeps, false takes away. sound = { rain = false } goes back to the synthesised rain and leaves everything else; sound = false goes back to the synthesised storm entirely.
You supply the sound; the engine keeps the mix. How loud it is follows the weather, and a roof still muffles it — only the player's own machine knows they just walked under an awning, so a level baked into the file could not know it.
Thunder, by strength
Up to six recordings. Each declares the one strength it is the sound of — 0 is a bolt on the horizon, 1 is one overhead — and the engine plays whichever is nearest to the strike that actually fell. It is the same falloff the flash was drawn with, so the clap matches the bolt the player just saw.
Nearest-match rather than ranges, so there is no gap to fall into: one entry answers for the whole storm, two split it, and no strength is ever silent.
Three ways to write it, shortest first:
luau
thunder = "world/sons/trovao" -- one, for everything
thunder = { "longe", "medio", "perto" } -- spread 0 → 1, in the order given
thunder = { -- said exactly
{ sound = "longe", strength = 0.15 },
{ sound = "perto", strength = 0.9, volume = 1.4 },
}Name them on the world library (world/…) if you want them to survive a restart. A name out of the object's own Contents works and is re-established the next time that script runs — but the world comes back up before any object does, so the first storm after a restart would be the synthesised one.
Sound
play_sound
world:play_sound(name, opts) → true, or nil, reason
Makes a noise at a point in the region — an impact, an explosion, anything whose source does not survive it.
luau
world:play_sound("impact", { pos = hit.pos, range = 60 })pos is required: a world sound has nowhere natural to happen. For a sound that comes from an object and follows it, use self:play_sound, whose options this shares exactly.
It is pos and not at because at in this API is always a socket on a body — one word could not be both a place in the region and a place on a person.
The asset still comes from the calling object's contents. The point is arbitrary; the sound is not.
It cannot loop, and the refusal says so: emitters are addressed by the object they sit on, and this one sits on nothing, so a loop here could never be stopped. Put the loop on an object with self:sound.
Needs the same build permission world:spawn answers to — putting a sound at an arbitrary point in somebody's region is putting something in their region.
Shared memory
store
The region's own drawer: every script in this world reads and writes the same one. self.store is each object's private memory — nothing else can see it, which is what makes it the wrong place for a scoreboard, a shop's till, or a quest everybody is on.
luau
local world = require("world")
world.store:set("shop.bread", "3")
print(world.store:get("shop.bread")) -- "3", from any object hereIt survives a restart and the world being moved to another machine — it rides inside the world's own save.
get
luau
world.store:get(key) -> text | nilnil when nothing was ever written there. Reading needs no permission: everyone standing in the region can already see the scoreboard.
set
luau
world.store:set(key, text | nil) -> true | nil, reasonnil as the value removes the key. Writing needs the world's datapermission — your own world's scripts have it, because you own the world.
update
luau
world.store:update(key, fn) -> new value | nil, reasonThe only correct way to change a value that depends on the old one. Your function receives the current value (nil if unset) and returns the new one (nil to delete).
luau
world.store:update("gold", function(current)
return tostring((tonumber(current) or 0) + 10)
end)Do not read then write. This looks equivalent and is not:
luau
local n = tonumber(world.store:get("gold")) or 0
world.store:set("gold", tostring(n + 10))Between those two lines another script's write can land — and be erased. It is the kind of bug that only appears when the world is busy and never reproduces when you go looking. update makes it one step, so nothing can slip in.
How much fits
| keys | value size | total |
|---|---|---|
| 4096 | 64 KB | 8 MB |
Room for a leaderboard, a price list, a row per regular. It is a store, not a database — a full one still reads, overwrites and deletes, and nothing of yours is erased behind your back.
Values are text: use json to keep a table in one.
See also
- json — putting a table into a store
- Saving data — the recipe
- world.terrain — sculpting the ground
- world.blocks — the cubic volume
- world.build — buildable pieces and sockets
- self — the object your script is in
- players — who is here
