Older trigger systems had an "op limit" that stopped a trigger mid-run and told you nothing. Here every budget is named, every overrun is reported with the trigger and the tick, and a trigger that keeps overrunning is switched off rather than allowed to drag the whole match down.
Every number on this page is read from the engine constant that enforces it. None of them is typed into the page, so none of them can be out of date.
The tick is the unit of everything
Ordinary RTS lockstep runs at 20 ticks per second, so one tick is 50 ms. Trigger durations and the budgets below use ticks. Explicit local profiles can change the rate; a higher rate changes the amount of work requested per second and the pacing of tick-based mechanics. The launch warning explains that multiplayer and deterministic RTS replay are unavailable for those profiles.
JavaScript and TypeScript budgets
Scripts have a separate instruction meter and memory limits. Trigger actions called through sos.triggers.run also consume the trigger engine’s action and expression budgets. The public scripting API charges world scans and bounds the number of returned entities.
| Limit | Current value |
|---|---|
| Scripts per map | 4 |
| Compiled JavaScript across the map | 524,288 bytes |
| Script memory across the match | 67,108,864 bytes |
| Stack per script | 32,768 bytes |
| Instruction-cost units per script per tick | 1,600 |
| Instruction-cost units across scripts per tick | 4,000 |
| Recoverable faults before disabling a script | 5 within 600 ticks |
| Persistent storage per script | 262,144 bytes |
| Persistent storage across scripts | 1,048,576 bytes |
An uncaught script exception can earn a strike; memory traps, invalid storage and restoration mutations disable a script immediately. Limits are measured in instructions and ticks, so machine speed does not decide which client disables a script. Test Map → Trigger Debug → Scripts shows gas, strikes, timers and stored state.
The four per-tick trigger budgets
These are the ones you can actually hit. They are checked at every action, every loop iteration and every function call.
| Budget | Per tick | What it counts |
|---|---|---|
| Actions | 40,000 | Every action executed, in every trigger, across the whole map |
| Loop iterations | 250,000 | Every pass through a while or forEachInt or forEachUnit body |
| Expression evaluations | 500,000 | Every expression evaluated, plus one per unit walked by a group scan |
| Function calls | 100,000 | Every call to a function you declared |
The expression budget is the one worth understanding, because it is the one
that changed shape. unitsInRegion, unitsOfPlayer, unitsOfType and
unitsWithTag each charge one evaluation per unit they walk, not one per
call. A single unitsInRegion on an eight-seat map with two thousand units
costs two thousand, not one.
That sounds punitive until you see what it fixed. Before the charge was honest, a
map with one group scan inside a forEachUnit body spent 2.4 seconds in a
single tick, which is forty-seven ticks' worth of time, while passing every
budget with room to spare and recording no faults at all. Deterministically, so
every client froze identically and nothing reported a desync. The budget was
generous because it was measuring the wrong thing.
The headroom is still large. The heaviest trigger-carrying map that ships with
the game peaks in the low thousands of charged evaluations in its worst tick,
against a cap of 500,000. Extrapolated to a
pathological population of two and a half thousand entities, the same script
reaches about 29,000, still comfortably under. You are unlikely to hit this by
accident; you will hit it if you put a group scan inside a loop.
The pattern that costs the most
forEachUnit over units in region "arena" <- 200 units
if units in region "arena" is not empty <- 200 more, EVERY iteration
...That is 40,000 charged evaluations from two nodes. Hoist the scan out of the
loop: build the group once, store it, iterate it. groupFilter narrows a group
you already hold without rebuilding it from the world, and closestUnit,
unitsInRadius and groupSortBy replace whole hand-written scan loops.
What happens when you cross one
- The work stops at a defined point. Not a random one: the counters are part of the hashed simulation state, so every client stops in the same place. A truncated loop is not a desync.
- A fault is recorded against that trigger, with the tick and which budget it was.
- Three faults on three separate ticks inside
200ticks disables that trigger for the rest of the match. Distinct ticks, deliberately: one heavy frame that trips three different budgets does not kill a trigger that is merely expensive. - The disable outranks your own
enableTrigger. A map cannot switch a trigger the engine killed back on. It also survives a save and reload, because a reload that resurrected it would hand you a match that behaves differently from the one you saved.
The fault ledger keeps the last 32 records,
and it is part of the save file and the sync hash, which is why it is small.
Seeing it happen
- The console always says so, in plain English, naming the trigger, the count and the budget: "trigger 'wave spawner' has been DISABLED for the rest of the match: it hit a runtime budget on 3 separate ticks inside 200".
- The Trigger Debug panel shows per-trigger fire counts, action counts and
faults live. It appears when you launch from The Forge's Test Map (
Ctrl+F9), and toggles withCtrl+Shift+F9. Faults appear in its trace view. - A disabled trigger is the single most likely explanation for "my map worked and then stopped". Look here before anywhere else.
Per-execution caps
Separate from the per-tick budgets, and hit much sooner in a runaway loop.
| Cap | Value | Applies to |
|---|---|---|
| Loop iterations per run | 10,000 | One while or forEachInt, per execution |
| Function call depth | 32 | Recursion. Past it, a call yields the zero value |
| Custom event cascade | 64 | raiseEvent chains inside one tick |
Units per createUnits | 500 | One action |
| Spawns per tick | 1,000 | Every createUnits in the map, in one tick |
| Entities alive | 20,000 | The whole match |
Region scanning, the cap that is easy to trip by accident
A region is watched when some trigger carries a unitEntersRegion or
unitLeavesRegion event naming it. Every watched region's occupant list is
recomputed on every tick, before any event fires, whether or not anything
moved.
- At most
256regions may be watched. This is the only cap on that pass, and crossing it is a validator error. - Regions you declare but never watch cost nothing per tick. You may have up to
2,000of those.
So declaring three hundred regions is fine. Putting an enter event on three hundred regions is not.
Interface and presentation caps
All per match, all validator errors when crossed.
| Cap | Value |
|---|---|
| Text banners | 64 |
| Minimap pings | 128 |
| Sounds per tick | 64 |
| Camera commands per tick | 64 |
| Custom HUD frames | 512 |
| Quest-log entries | 128 |
| Dialogs | 32 |
| Dialog buttons each | 32 |
| On-screen counters | 128 |
| Leaderboard rows | 128 |
| Cutscenes | 16 |
Pending waits | 4,096 |
| Shipped sounds you may name | 3,857 |
That last row is not a cap, it is the size of the library: every shipped sound
effect, voice line and critter call is nameable by playSound, using the bare
file name with no directory and no extension. A sound plays whether or not the
match happened to preload it.
Data caps
| Cap | Value |
|---|---|
| Global or hashtable array length | 8,192 |
| Local array length | 1,024 |
| Hashtables | 64 |
| Entries per hashtable | 16,384 |
| Hashtable key length | 256 |
| String length | 65,536 |
| Function parameters | 16 |
| Text shown to a player, per action | 256 |
| An id you write in a trigger | 64 |
| A hashtable or counter key from a trigger | 32 |
File size
| Cap | Value |
|---|---|
| A whole map | 4,194,304 bytes |
| A fragment (a trigger script or override set on its own) | 1,048,576 bytes |
| Trigger script nesting depth | 256 |
| Trigger script nodes | 2,000,000 |
| Map edge length | 8 to 1,024 tiles |
The map cap is on logic, not on art. A cap on the whole map file would include every model, icon and sound, and push authors into compressing art. Custom assets here are content-addressed and streamed separately, so they do not count against this at all. What counts is terrain, entities, triggers and overrides.
Ask for a frame rate
The game's adaptive graphics trade detail (shadows, grass, clouds, render
resolution and more) to hold a frame rate, 30 frames a second unless the
player chose otherwise. A map that needs to feel smoother can ask for more
with rules.targetFps (20 to 120): the match aims for the higher of the map's
request and the player's own setting, so a map can ask for more smoothness but
never for less. Every Wheels of Stone circuit asks for 60, because a race at
30 reads as stutter.
{ "rules": { "targetFps": 60 } }Budgeting your map
A rough order of operations, cheapest first:
- Prefer an event to a poll.
unitDiescosts nothing until a unit dies. Aperiodictrigger that scans for dead units costs every tick forever. - Widen the period. A
periodicevery 20 ticks costs a twentieth of the same work every tick, and almost nothing a player can perceive. - Hoist scans out of loops. This is the single biggest win available.
- Narrow before you iterate.
unitsInRadiuswith a small radius is far cheaper thanunitsOfPlayerfiltered inside the loop body. - Store what you will reuse in the same tick. A unit group in a variable is free to read.
- Watch few regions. Ten watched regions are ten full scans per tick.
Related
- Troubleshooting by error code for the
resourcecodes, which are the ones that report these caps. - Determinism for authors for why a truncated loop is safe rather than a desync.
- Testing for measuring a map rather than guessing.
- Triggers for the node set itself.