Skip to content

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 me

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

KeyWhat you get
shapea built-in shape: "box" (default), "sphere", "cylinder", "cone", "prism", "pyramid", "torus", "tube", "ring", "arch", "lbeam" (an L), "channel" (a U you can walk down)
modela model somebody made — a name from this object's Contents, or a path on the world's library (world/meshes/crate)
copytrue — 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?

KeyTypeMeaning
pospointa place in the world. Defaults to wherever the calling object is
holdstringstraight into a player's hand — their id
atstringwhich socket, when hold is given. "right_hand" by default
griptablehow 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.

KeyTypeMeaning
scalenumber or pointa number is uniform. A shape with no size is half a metre; a model with no size is the size its author built it
rotpointorientation in degrees
facingpointlay it flat against a direction — see below
topointwhere it is going — makes this a ballistic spawn
secondsnumberhow long the flight takes. Required with to
easestringcurve of the flight: "linear" (default), "in", "out", "in_out"
lifenumberseconds before it disappears. Default 30, maximum 300. 0 = as long as the region runs
namestringdisplay name
colorstring or table"#rrggbb" or { r, g, b }
transparencynumber
metallicnumber
roughnessnumber
emissivenumber
collidebooleanfalse 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,
    })
end

Without 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

ReasonMeaning
"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)
end

Worth 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)
end

Three answers, and they mean different things:

AnswerMeaning
a hitit met something
nilclear line — it met nothing within reach. Not an error
nil, whythe call was wrong: no from, no aim, a zero direction. Fix the code

Options

KeyTypeMeaning
frompointrequired — where the ray starts
topointaim at this point. The ray stops there
dirpointaim along this direction. Need not be unit length
maxnumberreach in metres. Defaults to the distance to to, or to 512 with dir. Always capped at 512
playersbooleanlet it hit player capsules. Default true
ignorenumber or listobject ids the ray passes through. Your own object is always ignored
ignore_playerstring or listplayer 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

FieldMeaning
what"object", "player" or "ground"
objectthe object id — only when what == "object"
playerthe player id — only when what == "player"
nametheir display name — only when what == "player"
zonewhere on them: "head", "body", "legs"
posthe contact point, in world space
normalthe surface direction at the contact, always facing back toward from
distancemetres 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()
end

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

FieldMeaning
rain0 = clear .. 1 = downpour
snow0 = none .. 1 = heavy
lightning0 = never .. 1 = every few seconds
windhorizontal wind speed, m/s
wind_dirwind 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.

KeyRangeMeaning
rain0 – 1how hard it is raining
snow0 – 1how hard it is snowing
lightning0 – 1how often it strikes
wind0 – 40m/s
wind_dirdegreeswraps, so 370 is a legible way to write 10
fadesecondscross-fade into this. 0 (default) snaps
soundtablethe 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:

snowyou can see about
0.21.5 km — decorative
0.4190 m
0.655 m
0.823 m
1.012 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 },
        },
    },
})
KeyWhat it is
rainthe rain bed — looped, its level following rain
snowthe snow bed
windthe wind bed — its level follows wind, so it is silent in still air
thunderone 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 here

It 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 | nil

nil 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, reason

nil 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, reason

The 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

keysvalue sizetotal
409664 KB8 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

Hungrit scripting documentation.