Map Editor Documentation

Performance and limits

The real per-tick budgets a map may spend, every hard cap with its live value, what happens when you cross one, and how to see it happen.

Advanced · Page 28 of 49

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.

LimitCurrent value
Scripts per map4
Compiled JavaScript across the map524,288 bytes
Script memory across the match67,108,864 bytes
Stack per script32,768 bytes
Instruction-cost units per script per tick1,600
Instruction-cost units across scripts per tick4,000
Recoverable faults before disabling a script5 within 600 ticks
Persistent storage per script262,144 bytes
Persistent storage across scripts1,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.

BudgetPer tickWhat it counts
Actions40,000Every action executed, in every trigger, across the whole map
Loop iterations250,000Every pass through a while or forEachInt or forEachUnit body
Expression evaluations500,000Every expression evaluated, plus one per unit walked by a group scan
Function calls100,000Every 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

  1. 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.
  2. A fault is recorded against that trigger, with the tick and which budget it was.
  3. Three faults on three separate ticks inside 200 ticks 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.
  4. 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 with Ctrl+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.

CapValueApplies to
Loop iterations per run10,000One while or forEachInt, per execution
Function call depth32Recursion. Past it, a call yields the zero value
Custom event cascade64raiseEvent chains inside one tick
Units per createUnits500One action
Spawns per tick1,000Every createUnits in the map, in one tick
Entities alive20,000The 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 256 regions 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,000 of 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.

CapValue
Text banners64
Minimap pings128
Sounds per tick64
Camera commands per tick64
Custom HUD frames512
Quest-log entries128
Dialogs32
Dialog buttons each32
On-screen counters128
Leaderboard rows128
Cutscenes16
Pending waits4,096
Shipped sounds you may name3,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

CapValue
Global or hashtable array length8,192
Local array length1,024
Hashtables64
Entries per hashtable16,384
Hashtable key length256
String length65,536
Function parameters16
Text shown to a player, per action256
An id you write in a trigger64
A hashtable or counter key from a trigger32

File size

CapValue
A whole map4,194,304 bytes
A fragment (a trigger script or override set on its own)1,048,576 bytes
Trigger script nesting depth256
Trigger script nodes2,000,000
Map edge length8 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:

  1. Prefer an event to a poll. unitDies costs nothing until a unit dies. A periodic trigger that scans for dead units costs every tick forever.
  2. Widen the period. A periodic every 20 ticks costs a twentieth of the same work every tick, and almost nothing a player can perceive.
  3. Hoist scans out of loops. This is the single biggest win available.
  4. Narrow before you iterate. unitsInRadius with a small radius is far cheaper than unitsOfPlayer filtered inside the loop body.
  5. Store what you will reuse in the same tick. A unit group in a variable is free to read.
  6. Watch few regions. Ten watched regions are ten full scans per tick.