Sandbox and limits
How scripts are isolated in a locked-down Web Worker, the time and command budgets, the watchdog, and good debugging practice.
A published game can be opened by anyone. The scripts it contains therefore run in a strict sandbox: they see neither the page, nor the wallet, nor the network, and a slow or stuck script cannot freeze the game.
Architecture
The engine (main thread) and the scripts (a dedicated Web Worker) share no memory. They only exchange structured messages:
The engine sends a snapshot
At most 30 times per second: time, player, entities, variables, HUD. World events (touch,pickup…) are sent as they happen.The worker runs your handlers
Each handler has a time budget and queues its commands.The engine applies the commands
One answer per message, applied on the next frame after validation.
Security model
Isolation
Scripts run in a Web Worker: no DOM, no window, no page cookies or storage. On start, the worker replaces with undefined every global that could be used to get out:
fetch XMLHttpRequest WebSocket EventSource importScripts postMessage Worker SharedWorker indexedDB caches navigator close Request Response Headers FileReader FileReaderSync BroadcastChannel MessageChannel Atomics SharedArrayBuffer WebAssembly scheduler crossOriginIsolated location origin
On top of that, every script is compiled with those names (plus self, globalThis, window, document) declared as parameters set to undefined. Even a script that got hold of the global object would find nothing to call.
One bridge
The only way in and out is the message channel captured before the lockdown. Your scripts never see it: they only see the sandbox object, frozen with Object.freeze.
Structured data, validated commands
- Snapshots and commands are plain JSON-like objects: a script cannot hand a function or a live object to the engine.
- Engine side, unknown ids are ignored, numbers are coerced, the player cannot be removed and spawns are capped.
- Every script is compiled in strict mode (
"use strict").
What a script cannot do
Limits
The values come from the engine’s LIMITS constant and the engine’s spawn guards.
| Limit | Value | Past it |
|---|---|---|
| Handler budget | 50 ms | reported as an error; 3 slow handlers in a row switch the script off |
| Stuck worker (watchdog) | 50 + 100 ms | the worker is terminated, the error reported, a fresh worker starts with the same scripts and start fires again |
| Snapshot rate | 30 / s | ticks are coalesced: dt accumulates |
| Commands per handler | 200 | the rest is dropped, with an error |
| Log lines per handler | 20 | the rest is dropped |
| Runtime errors in a row | 10 | the script is switched off |
| Scripts per document | 16 | extra scripts do not run |
| Code size per script | 32,000 characters | the script does not run |
| Spawns per frame / total actors | 20 / 400 | extra spawns are ignored |
The watchdog
Every message sent to the worker expects exactly one answer. If it does not arrive within the budget plus transit time (150 ms), the engine considers the worker stuck (infinite loop, huge allocation): it terminates it, shows a fatal error in the console, starts a fresh worker with the same scripts and fires start again.
Keep state in variables
sandbox.set) are kept. Write your start handlers so they can run again, for example by only initialising a variable when sandbox.get returns undefined.sandbox.on('start', () => {
if (sandbox.get('lives') === undefined) sandbox.set('lives', 3)
})Tick coalescing
A tick is never sent while the previous one is unanswered. A slow script therefore lowers its own tick rate instead of piling up work: the time not sent is added to the next dt. Always use dt for time-based maths rather than assuming 30 ticks per second. A consequence of the protocol: commands are applied one frame late.
Debugging
- Run check (
Ctrl + Enter) compiles the script without running it: useful for syntax errors. - The Scripts panel console keeps the last 50 lines:
sandbox.log()output (prefixed with the script name), runtime errors with the event involved, budget overruns and restarts. - Avoid logging on every
tick: past 20 lines per handler, the rest is dropped. - A script switched off after too many errors comes back on the next test run.
sandbox.on('pickup', (e) => {
sandbox.log('pickup', e, 'score', sandbox.hud.score)
})