world#
world is the region your object stands in. Everything that is not the object itself.
Use it to
- make new objects appear, and move or remove them
- find out what is in the way of a line
- run something when the world starts up
- change the weather for everyone
- keep data every script in the world can read, like a leaderboard
- shape the ground, the sky and the sea, through the pages below
local world = require("world")Unlike self, this one must be required. There is no world global.
At a glance#
What you want done, and the verb that does it.
Make things
Ask about the world
Time
Weather and sound
Shared memory and menus
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.
world:spawn({ shape = "sphere", pos = here }) -- a built-in shapeworld:spawn({ model = "arrow", pos = here }) -- from my Contentsworld:spawn({ model = "world/meshes/crate" }) -- from the world libraryworld: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 makes 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.
Tip
model is what stops every dropped coin and shell casing from being a coloured cube. The script only ever says the name, and 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, by 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 |
Careful
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 script's owner holds Act on other people's avatars in this world, and the player has interacted with the object doing the handing. The new thing has no history of its own, because 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, which 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, which is what a purely visual effect wants |
facing, for 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.
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, for 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.
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" | the script's owner does not hold Create ephemerals in this world. The world's owner always does. See Permissions |
"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.
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".
Careful
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 = }, because 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.
-- a pool: the oldest mark disappears when the newest is borntable.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, NPCs and the ground, whichever comes first.
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 |
npcs | boolean | let it hit NPCs. 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 |
ignore_tag | string or list | objects carrying this tag are passed through: glass, a shooter's team. Only the tagged object itself, not the rest of its group |
Aim with either to or dir, never both.
Careful
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", "npc" or "ground" |
object | the object id. Only when what == "object" |
player | the player id. Only when what == "player" |
npc | the NPC id, for world.npc:get. Only when what == "npc" |
name | their display name. For a player or an NPC |
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. It is a plain growing number you compare against one you saved earlier.
self.store:set("planted", tostring(world:clock())) -- much later, possibly after a restartlocal planted = tonumber(self.store:get("planted") or "0") or 0if 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.
Tip
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.
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, because 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 and 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.
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.
Careful
Catch up with arithmetic, not with a loop. elapsed can be a month, which is 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.
HUDs#
on_hud#
world:on_hud(name, fn) → handle
Runs when a button on the world's HUD called name (one made in the editor's Interface tab) is set to Tell the scripts and a player clicks it. fn(player, event, data): who clicked, the message the button carries, and its details.
local world = require("world") world:on_hud("shop", function(player, event, data) if event == "buy" then print(player.name .. " wants " .. tostring(data.item)) endend)Any script in the world may listen, whichever script showed the HUD, or none, for a HUD that is on screen from the moment people enter. Registering the same name again replaces the handler.
Trust event and the player, not data. The details are whatever the button carried; a price in data is the price the button claims.
A HUD written as a table inside a script reports to self:on_hud in that script instead.
Free, and needs no permission.
Sky#
weather#
world:weather() → table
What the sky is doing right now. Never fails and needs no permission, because 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 to 1 | how hard it is raining |
snow | 0 to 1 | how hard it is snowing |
lightning | 0 to 1 | how often it strikes |
wind | 0 to 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, meaning 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, and 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.
Tip
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 Weather and ambience: the world's owner holds it, and so does anybody the owner granted it to. See Permissions.
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.
world:set_weather({ rain = 0.8, lightning = 0.6, sound = { rain = "world/sounds/rain", wind = "world/sounds/wind", thunder = { { sound = "world/sounds/thunder-far", strength = 0.15 }, { sound = "world/sounds/thunder-near", strength = 0.9 }, }, },})| Key | What it is |
|---|---|
rain | the rain bed, looped, with its level following rain |
snow | the snow bed |
wind | the wind bed, whose 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, because 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 and 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:
thunder = "world/sounds/thunder" -- one, for everythingthunder = { "far", "mid", "near" } -- spread 0 → 1, in the order giventhunder = { -- said exactly { sound = "far", strength = 0.15 }, { sound = "near", strength = 0.9, volume = 1.4 },}Tip
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.
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, because 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 that nothing else can see, which is what makes it the wrong place for a scoreboard, a shop's till, or a quest everybody is on.
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#
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#
world.store:set(key, text | nil) -> true | nil, reasonnil as the value removes the key. Writing needs the world's data permission. Your own world's scripts have it, because you own the world.
update#
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).
world.store:update("gold", function(current) return tostring((tonumber(current) or 0) + 10)end)Careful
Do not read then write. This looks equivalent and is not:
local n = tonumber(world.store:get("gold")) or 0world.store:set("gold", tostring(n + 10))Between those two lines another script's write can land, and then 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.
keys#
world.store:keys(prefix?) -> list of keys | nil, reasonEvery key that starts with prefix, sorted; every key at all without one. Name your keys like paths (score.ana, shop.bread) and a prefix becomes a folder. More than 1024 matches is nil, reason: ask for a narrower prefix.
top#
world.store:top(prefix, n?) -> list of { key, value }The leaderboard. The values under prefix that read as numbers, highest first, at most n of them (10 when you do not say, 100 at most). Each key comes back without the prefix, ready to print. Equal scores are ordered by key, so the same board always reads the same way.
world.store:set("score." .. player.id, tostring(points)) for i, row in world.store:top("score.", 5) do print(i, row.key, row.value)endIt reads every key under the prefix, so call it when the board changes, not on every tick.
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 puts a table into a store
- Saving data is the recipe
- world.terrain sculpts the ground
- world.blocks is the cubic volume
- world.build covers buildable pieces and sockets
- self is the object your script is in
- players is who is here
Updated 17 October 2026