How scripts run#
What actually happens while your script is alive: when it wakes, when it sleeps, what Save really does, and where the ceilings are. Reading this once saves a lot of guessing later.
A script is asleep almost all the time#
Your script runs in short bursts called slices. A slice is one of two things:
- the script being loaded, which means your file read top to bottom, once
- one event firing, such as
on_touch,on_timeroron_action
Between slices it does nothing and costs nothing. There is no loop running in the background and no "every frame" to hook into. If something should happen over and over, you ask for a timer.
local self = require("self") self:on_timer(1, function() -- once a second, foreverend)The body declares, the events act#
The top level of your file runs once, when the script loads. Use it to set things up: constants, handlers, timers.
--!strictlocal self = require("self") local SPEED = 2 -- a constant, fine herelocal visits = 0 -- a variable, fine here self:on_touch(function() -- a handler, declared here and run later visits += 1 print(visits, SPEED)end)What the body cannot count on is the rest of the world existing yet. The region starts its scripts one object at a time, so while your body runs, other objects may still be loading.
If you need to act on the world at startup, positioning things, setting the weather, starting a round, use world:on_start. It runs after the last script in the region has finished loading.
local world = require("world") world:on_start(function() world:set_weather({ rain = 0.2 })end)Attach a script to a world that is already running and on_start fires on the next tick, because for that script the world really is ready straight away.
Saving restarts the script#
When you save a new version, the old one is thrown away completely and a fresh one starts.
| local variables | gone |
| handlers | gone, then declared again |
| timers | gone, then armed again |
self.store | survives |
That is the whole rule, and it is also why saving is the way to rescue a script that has got itself into a strange state.
The one thing that runs before the old version dies is on_detach, and only on worn items. It is the last moment such a script can write to its store.
Regions sleep, and time keeps moving#
A region with nobody in it stops ticking. Your timers do not fire while it is asleep. When somebody arrives, it wakes up.
That matters for anything that should have made progress in the meantime: crops, ore coming back, a shop restocking. Two verbs exist for exactly this.
local world = require("world") -- Seconds this WORLD has lived. Keeps counting while the world is off.local now = world:clock()print(now) -- Fires when the world resumes after time nobody simulated.world:on_wake(function(elapsed) print("nobody was here for " .. tostring(elapsed) .. " seconds")end)The pattern that always works is to save a timestamp, not a countdown:
local self = require("self")local world = require("world") local function ripen() end -- when it was plantedself.store:set("planted", tostring(world:clock())) -- later, possibly after a restartlocal planted = tonumber(self.store:get("planted") or "0") or 0if world:clock() - planted >= 3600 then ripen()endA countdown only moves while somebody is there to tick it. A timestamp is true whether anyone was watching or not.
Catch up with arithmetic, never with a loop
elapsed can be a month, which is over two million seconds. for i = 1, elapsed do will exhaust your instruction budget and your script will be stopped. Divide instead.
When something goes wrong#
There are two very different outcomes, and telling them apart is most of debugging.
An ordinary mistake, such as indexing something that is nil, calling a method that does not exist, or doing arithmetic on text. The event is abandoned, the error goes to your console, and the script stays alive. The next click works fine.
Running away, meaning an endless loop or too much memory. The script is stopped and killed. It does not run again until you save it. You get one of these two messages:
| Message | What happened |
|---|---|
script budget exceeded | one slice used more than 50,000 instruction units |
script memory limit exceeded | the script went past its 4 MB |
In practice both mean the same thing: something looped with no way out. Timers are the fix. One slice may not take long, but you can have as many slices as you like.
The ceilings#
These exist so one object cannot spoil a whole region. You will not meet most of them by accident.
Per slice, meaning one event or the load#
| Limit | Value |
|---|---|
| Instruction units | 50,000 |
| World effects, such as moves, spawns and property writes | 8 |
Events sent with events:emit or events:send | 16 |
print lines | 32 |
Length of one print line | 512 bytes |
Per script#
| Limit | Value |
|---|---|
| Memory | 4 MB |
Keys written by one self:set call | 32 |
Per object#
| Limit | Value |
|---|---|
| Scripts | 8 |
| Tags | 16 |
| Store keys | 256 |
| Size of one stored value | 64 KB |
| Store total | 1 MB |
Per region#
| Limit | Value |
|---|---|
| Active scripts | 768 |
| Objects created by scripts, alive at once | 256 |
| Named actions | 32 |
The complete list, with every refusal message and what it means, is in Limits and refusals.
The eight effect rule, in practice#
The one ceiling you will genuinely feel is 8 world effects per event. Moving something, spawning something, writing properties, playing a sound: each of those is one effect.
local self = require("self")local world = require("world") self:on_touch(function() for i = 1, 20 do world:spawn({ shape = "sphere" }) -- the 9th one fails endend)The ninth call hands back nil, "too many commands". Nothing crashes. It simply does not happen, and the reason says why.
The fix is to spread the work across time:
local self = require("self")local world = require("world") local made = 0self:on_timer(0.2, function() if made >= 20 then return end made += 1 world:spawn({ shape = "sphere" })end)Where each thing runs#
You never choose this, but knowing it explains why some things feel instant and others do not.
On the server goes everything that changes the world: moving objects, damage, spawning, sound, the store. Everyone sees the same result and nobody can edit their own copy to cheat.
On each player's own machine go the things that would feel broken with a delay:
- menu navigation, sliders and toggles, where only a button that emits reports back to you
- the crosshair
- a weapon's recoil and the spread of its shot
- the optional click preview, so a door starts moving on the frame of the click
The practical result is that a HUD with ten pages costs the server nothing, and a weapon's kick lands on the frame you pulled the trigger instead of a round trip later.
Next#
- When nothing happens is the checklist for silence
- Saving data covers the store in depth
- Limits and refusals is the complete table
Updated 04 September 2026