NEOLearn

Limits and determinism

The fuel, call, memory and recursion limits on scripts, what happens when a script goes past them, and the rules that keep scripts deterministic.

Updated October 17, 2026

On this page

Every script runs under fixed budgets, so a slow or broken script can't stall the game, and the same script stops at exactly the same point on every device. This page lists the limits, what happens when one is reached, and the rules that make scripts deterministic.

Budgets#

Budgets are set in the [script] section of neo.toml for the game's own scripts, and in [script.players] for players' live scripts:

KeyMeasuresGame's scripts defaultPlayer scripts default
fuel_per_tickInstructions per script per tick, shared by all its handlers that tick2,000,000200,000
fuel_per_callInstructions for one call: one event delivered to the script, or one tick1,000,000200,000
calls_per_tickWorld calls per script per tick10,000200
memoryMemory per script, as bytes or a string such as "16MB"64 MB4 MB
neo.toml
[script]
language = "js"
scripts = ["assets/scripts/platform.js"]
fuel_per_tick = 1_000_000
fuel_per_call = 500_000
calls_per_tick = 2_000
memory = "16MB"

[script.players]
allowed = "nobody"
fuel_per_tick = 100_000
calls_per_tick = 100
memory = "4MB"

Budgets are game constants: every player's scripts get the same budget, whatever their role or device. Fuel counts engine instructions, not time, so a fast machine and a slow one stop a script at the same instruction.

Fuel#

Each call into a script (delivering an event, or running its tick) gets fuel_per_call instructions, or whatever is left of the script's fuel_per_tick budget for this tick if that is less. Built-in work such as growing a string is charged too.

When a call runs out:

  • the handler stops with an error that can't be caught: a try/catch around an endless loop doesn't help;
  • it's recorded as a fault (NoInstructionsRemainError: instruction budget was exhausted);
  • once the tick budget is used up, the script's remaining calls that tick fault too;
  • the next tick starts with a fresh budget, and the script runs normally again.

The Scripts panel and script.list show each script's fuel used last tick against its budget.

World calls#

Each read or write of an entity field, each state write, and each call to emit, query, spawn or an exposed method counts as one world call. Past calls_per_tick, the next call throws RangeError: too many world calls this tick (catchable). The count resets every tick.

Exposed methods can also have their own rate, set in Rust (rate = n calls per simulation second, per script). Going past it throws TypeError: Kind.method is limited to n calls per second.

Memory#

Memory is counted in heap objects, not bytes, so the limit falls at the same point on every platform: the budget allows memory / 64 live objects (at least 1,024). Each script has its own account, so one greedy script can't use up another's memory. Past the limit, the handler stops with an uncatchable HeapLimit error and a fault. The game itself keeps running.

Recursion#

A call stack can be 256 frames deep. The first time a script goes past it, it gets an ordinary error it can catch (RangeError in JavaScript, RecursionError in Python). If it recurses again while handling that error, the handler stops with an uncatchable fault.

Live script size#

A player's live script must also pass the game's moderation check before it runs. The default check refuses sources over 64 KB.

What a limit does to the rest of the game#

A script that hits a limit only loses the rest of that handler. Changes it made before the limit stay made, its other handlers and later ticks run, and other scripts aren't affected. Its fault count and last fault are shown in the Scripts panel and by script.list.

Determinism#

The same scripts, inputs and world seed give the same result, with the same state hash, on every run and platform. NEO makes that hold by construction:

Source of differenceHow NEO removes it
Wall clockThere is none. this.time, Date and sleep all use simulation time. Date counts from 2026-01-01T00:00:00Z.
Time zonesEvery machine is UTC.
Randomnessthis.rng.random() and Math.random() draw from one stream seeded from the world seed and the script's name.
Running other codeeval and new Function are disabled, including the async and generator function constructors. Python has no eval, exec or compile.
Garbage collectionWeakRef and FinalizationRegistry are removed, so collection timing can't be observed. SharedArrayBuffer and Atomics are removed too.
Changing built-insEvery built-in object and prototype is frozen before scripts run. Math.random = ... or Object.prototype.x = 1 throws a TypeError.
GlobalsA script can't create global variables, so one script can't leave anything for another.
OrderScripts run in load order, handlers in subscription order, timers by (due time, creation order), promise callbacks first in, first out.
Hash orderMaps and dicts iterate in insertion order; Python sets too, and Python's hash() has no random seed.
MathsMaths functions give bit-identical results on every platform.

Two things are still up to you:

  • Use the world's randomness. Draw from this.rng.random() (or Math.random() in JavaScript), never from anything you build from data that differs between machines.
  • Keep game state in this.state. The state hash covers state, the fields the script can see, and what the script did. Values kept elsewhere aren't checked and don't survive a reload.

See also#