An RTS lockstep map runs on every player's machine at once. Nobody sends anybody the world; each client runs the same simulation from the same inputs and trusts that it gets the same answer. That is lockstep, and it is why a custom map in a lockstep game can "desync" and split a game in half with no explanation.
The short version for authors: you cannot write a nondeterministic trigger here. Not "you should not". The node set does not contain the tools you would need. This page explains what was removed and why, because two or three of the consequences are surprising and will otherwise read as bugs.
The one-paragraph reason
Determinism means: the same map, the same inputs, the same result, on every machine, every time. If two clients ever compute a different number, they are playing two different matches from that instant, and every later decision compounds it.
The classic way that happens is not a logic bug. It is arithmetic.
Math.hypot(20, 99) returns 100.99999999999999 in Chrome and exactly 101 in
Firefox. That is not a rounding detail you can shrug at: a trigger asking "is
this unit within 101 tiles" answers yes for your Firefox opponent and no
for you, in the same tick, in the same match. Roughly a third of integer grid
distances disagree between those two engines.
So the maths is not left to the browser. Every arithmetic node here computes
through the engine's own implementations, ported from the reference algorithms
Chrome's own maths library uses, so the answer is the same byte for byte
everywhere. That is the whole reason there are sqrt, pow, sin, cos,
atan2 and distance nodes for JSON triggers. JavaScript and TypeScript use the same pinned QuickJS WebAssembly interpreter on every client, with deterministic instruction budgets, seeded randomness and constant clock/environment shims.
Scripts and local simulation
Register script callbacks at the top level and keep persistent state in sos.storage. Script RNG, timers and storage participate in save/load and multiplayer checksums. Global variables and captured closures are recreated after loading, and onLoad is read-only. Unit queries return ordered ids. Your first sandboxed script gives the working API.
A local profile explicitly opts out of lockstep and deterministic RTS replay, with a warning before launch. It can run at a higher declared tick rate and use the public pegboard physics controller. It is refused in multiplayer. All remaining trigger determinism rules still apply to lockstep maps; local mode does not give scripts browser, network or clock access.
What the trigger engine guarantees for you
Five properties, each one a thing map authors usually have to manage by hand.
- There is one clock, and it is the tick.
tickis the current simulation tick, 20 per second. There is no way to read the wall clock, the date, the frame rate or how long anything took. A trigger cannot behave differently on a fast machine. - There is one random generator, and it is shared.
randandrandRealdraw from the match's seeded generator. Every client draws the same sequence, in the same order, because the whole sim advances it together. - Nothing throws. A missing unit, a variable you never declared, a division
by zero, an out-of-range index: all of these give you the zero value instead
of an error.
divby zero is0, not infinity and not NaN.sqrtof a negative is0.logof zero is0. This is not sloppiness, it is the point: a node that threw on one client and returned on the other would skip the rest of that trigger on one side only, which is a desync. - Unit groups are always in a defined order. Every group is sorted before
you can iterate it, and
forEachUnitsnapshots and sorts before the first iteration. Where two units tie on a sort key, the lower entity id always wins, in both directions. You never get "whatever order the engine happened to store them in". - Text is handled conservatively.
strUpperandstrLowerchange onlya-zandA-Z, andstrTrimAsciistrips only the six ASCII whitespace characters. Anything else is left exactly as it was, because Unicode case rules and the Unicode whitespace set have both changed between browser releases, and these strings end up in the sync hash.
The five surprises
These are the places where determinism is visible from the author's chair. None is a bug.
Random draws happen whether you use them or not
rand(5, 5) still consumes a draw. randReal with equal bounds still consumes
a draw. randomPointInRegion consumes exactly two draws even when the region
does not exist. groupPickN consumes exactly min(N, group size) draws.
A conditional draw would advance the shared generator on one client and not the other, so every node that could draw always draws. The practical consequence: adding or removing a random node anywhere changes every later random result in the match. If you are chasing a repeatable balance test, change the maths, not the number of draws.
playerName returns "Player 1", not the lobby name
The lobby name is a per-client string, and anything a trigger stores is hashed
into the sync check. If your trigger wrote a name, two clients holding different
spellings of the same player would fork. So playerName deliberately answers
Player 1, Player 2, and so on. Use it for labels, not for identity.
Angles are radians, and due west is +pi
angleBetween, atan2, polarOffset, sin, cos and tan all work in
radians. angleBetween and atan2 return a value in -pi..pi, and due west
comes back as +pi, never -pi. Compare bearings with a tolerance rather than
for equality; floating point equality on an angle is a bug in any engine, and
here it is a bug that will look like a determinism problem when it is not.
Sorting a group and then storing it loses the order
groupSortBy returns the group in your order, but writing a unit group into a
variable canonicalises it, because a variable's contents are hashed and two
clients must agree on the bytes. Use a sorted group inline: feed it straight
into forEachUnit, or index it with groupAt. Storing it and reading it back
gives you the same units in id order.
trunc is the only real-to-integer rule
There is exactly one way a real becomes an integer implicitly, and it is
truncation toward zero. floor, ceil and round exist as explicit nodes when
you want something else. round sends halves toward positive infinity, so
round(2.5) is 3 and round(-2.5) is -2. Write the one you mean.
What a desync actually looks like now
If the simulation ever forks, the engine detects it, names the tick, and reports it. You do not get a silent disconnect and a shrug. The checks that back this up run on every change to the engine, not on hope:
npm run lint:determinismrefuses any use of the platform maths library, the wall clock or an unseeded random number in simulation code. Its budget for platform maths calls is zero.npm run test:determinismplays the same match twice and requires the results to be bit-identical.npm run test:desyncruns real clients in separate isolates and requires them to agree, which is the only check that catches a per-process difference.- A cross-engine harness runs the same match in Chromium, Firefox and WebKit and requires byte-identical results.
None of that is something you have to run. It is why the node set is shaped the way it is.
If your map is not reproducing
A map that gives different results from run to run is almost never a determinism problem, because determinism problems are not expressible. Check these first:
- A different number of random draws. See above: any change to how many random nodes execute reshuffles everything afterwards.
- A different seed. A new match seeds differently. A replay and a save restore the generator's state, so those do reproduce.
- A trigger that got disabled. A trigger that blows a per-tick budget on
three separate ticks inside
200ticks is switched off for the rest of the match, and stays off through a save and reload. That looks exactly like "my map stopped working halfway through". Performance and limits covers how to see it. - A region that no unit entered.
unitEntersRegionfires for units that move in. A unit already standing there when the match starts never fires it.
Related
- Performance and limits for the per-tick budgets and the fault ledger.
- Trigger reference for each maths node's exact behaviour at its edges. Every one of them says what it does with a bad input, because every one of them has to do something.
- Testing for running a map repeatedly and asserting the outcome.
- Capability map for what the node set covers and what it does not yet.