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:

  1. 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.
  2. The worker runs your handlers

    Each handler has a time budget and queues its commands.
  3. 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

Read other games, your project list, your wallet or the page; make a network request; load external code. A malicious script can at worst burn CPU inside its worker, and the watchdog stops it.

Limits

The values come from the engine’s LIMITS constant and the engine’s spawn guards.

LimitValuePast it
Handler budget50 msreported as an error; 3 slow handlers in a row switch the script off
Stuck worker (watchdog)50 + 100 msthe worker is terminated, the error reported, a fresh worker starts with the same scripts and start fires again
Snapshot rate30 / sticks are coalesced: dt accumulates
Commands per handler200the rest is dropped, with an error
Log lines per handler20the rest is dropped
Runtime errors in a row10the script is switched off
Scripts per document16extra scripts do not run
Code size per script32,000 charactersthe script does not run
Spawns per frame / total actors20 / 400extra 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

After a restart, your scripts’ JavaScript variables start from scratch, but game 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.
safe-start.jsJavaScript
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.
debug.jsJavaScript
sandbox.on('pickup', (e) => {
  sandbox.log('pickup', e, 'score', sandbox.hud.score)
})

See also