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.
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:
| Key | Measures | Game's scripts default | Player scripts default |
|---|---|---|---|
fuel_per_tick | Instructions per script per tick, shared by all its handlers that tick | 2,000,000 | 200,000 |
fuel_per_call | Instructions for one call: one event delivered to the script, or one tick | 1,000,000 | 200,000 |
calls_per_tick | World calls per script per tick | 10,000 | 200 |
memory | Memory per script, as bytes or a string such as "16MB" | 64 MB | 4 MB |
[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/catcharound 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 difference | How NEO removes it |
|---|---|
| Wall clock | There is none. this.time, Date and sleep all use simulation time. Date counts from 2026-01-01T00:00:00Z. |
| Time zones | Every machine is UTC. |
| Randomness | this.rng.random() and Math.random() draw from one stream seeded from the world seed and the script's name. |
| Running other code | eval and new Function are disabled, including the async and generator function constructors. Python has no eval, exec or compile. |
| Garbage collection | WeakRef and FinalizationRegistry are removed, so collection timing can't be observed. SharedArrayBuffer and Atomics are removed too. |
| Changing built-ins | Every built-in object and prototype is frozen before scripts run. Math.random = ... or Object.prototype.x = 1 throws a TypeError. |
| Globals | A script can't create global variables, so one script can't leave anything for another. |
| Order | Scripts run in load order, handlers in subscription order, timers by (due time, creation order), promise callbacks first in, first out. |
| Hash order | Maps and dicts iterate in insertion order; Python sets too, and Python's hash() has no random seed. |
| Maths | Maths 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()(orMath.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.