A custom map is untrusted content. Someone types a URL, or a host picks a map in a lobby, and code they have never seen runs on their machine. Every rule on this page exists because of that, and knowing the rules makes it obvious why some things you might expect to be possible are not.
Maps contain validated data and may carry sandboxed JavaScript or TypeScript. Scripts run inside QuickJS with fixed memory and instruction budgets. They cannot directly access the network, the DOM or real-time clocks. Local maps may request declared creator HTTPS endpoints through a permissioned host bridge. The public sos API is their only route to engine state. See Your first sandboxed script.
What a map cannot do, by construction
Sandboxed code execution
JSON triggers are interpreted as typed nodes; their function calls resolve within their declared trigger script. JavaScript and TypeScript run inside a separate QuickJS WebAssembly instance with fixed memory, stack and instruction limits. Code generation from strings and dynamic imports are unavailable.
A script receives the sos API and plain data, never a DOM node, engine entity object or renderer handle. Engine mutations go through validated operations. Native system registration requires an engine extension. The scripting guide describes callback registration, persistent storage and read-only restoration.
No raw network, filesystem or page access
Nothing in the trigger layer can fetch a URL, open a socket, touch storage or
reach the page. The one string a player can supply into the simulation is a
save code, and verifySaveCode treats it as a salted hash check bound to your map,
costed against the expression budget before it is even parsed. Local sandboxed scripts additionally have creator-scoped sos.progress saves and permissioned sos.network.request calls. Neither exposes browser storage keys, cookies, raw fetch or filesystem handles. See Player progression and creator APIs.
No clock
The current tick is the only time the language can read. Not the date, not the wall clock, not the frame rate, not how long anything took. See Determinism for authors for why.
Trigger values and script faults
Trigger expressions are total: missing units, undeclared variables, division by zero and out-of-range indexes return their defined zero values. JavaScript retains its normal exception semantics. The sandbox records recoverable exceptions and budget exhaustion as strikes; repeated faults disable a script, while fatal memory or restoration faults disable it immediately. Check the Scripts debugger before publishing. A script that cannot initialize refuses the match. These bounds protect clients; they do not prove that an author’s gameplay logic is correct.
No unbounded work
Per-tick budgets stop a runaway trigger and disable a repeat offender. Performance and limits has the numbers. Nesting depth in a data override, copy chains between cloned units and the number of regions a map may declare are all capped too.
What is checked, and where
Prototype pollution
Three key names are refused everywhere a map can write a key: __proto__,
constructor and prototype. This applies to an override's id, to every key
inside a patch at any depth, to a section name, and to the keys of a map's custom
sound and model tables.
Refusal is not the only defence, because a check you can only do once is a check you can miss. The same three names are also stripped during the merge that installs an override, independently of validation, and writes into the engine's definition tables go through a property definition rather than an assignment, so no key can reach a prototype setter even if it got that far. An entry that earns any error is dropped from the install entirely rather than partly applied.
An id that is one of those names reports forbidden-key. See
Troubleshooting.
Ids
An override id must be 1 to 64 characters of letters, digits, underscore and
hyphen, and must not be a forbidden name. A clone cannot keep its base's id, a
patch cannot rename a definition out from under the key it is filed under, and two
overrides cannot claim the same id. A bad id reports invalid-id.
There is no namespacing rule: your map's ids live in the same space as the shipped ones, and patching a shipped id is the intended way to change a unit. Nothing stops you naming something the same as a shipped id, which is a feature of the override system rather than a gap in it.
Sound ids
A sound id is one path segment: letters, digits, underscore and hyphen, and nothing else. No slash, no dot, no scheme, no encoded separator. The id is looked up against the shipped sound library or the map's own registered sounds, and an unknown id is a validator error with a closest-match suggestion, not a silent miss.
The reason the shape is that strict is that a sound id becomes a URL on every
client that plays it. Admitting a separator is what would make path traversal,
protocol injection and encoding attacks expressible at all. The rule is an
allow-list of what an id may contain, never a list of things it may not, and the
test suite behind it asserts refusal of forty-one specific attacks including
traversal, javascript: URLs, null-byte truncation, CRLF injection, HTML event
handlers and homoglyphs.
Custom asset URLs
A map may reference art and audio it ships or hosts. What is accepted differs between the two routes, and the packaged route is stricter:
| Route | Sounds | Models |
|---|---|---|
A .sosmod package | A data: URI up to 256 KB, or https:// | Same |
| A data-override set | data:, https://, /api/…, or localhost during development | data:, https://, http:// with a warning, or /assets/…, and the path must end .glb or .gltf |
An oversized or over-complex model is rejected at load and the two-dimensional sprite is drawn instead, rather than the match stalling. A player can also turn off loading models from third-party hosts entirely, in which case such a model is refused and the sprite is used; that setting is on by default.
Sounds are presentation only. A client that cannot decode one plays silence and runs the identical simulation, so a failed sound can never fork a match.
The validator, at publish and at load
The same validator runs in The Forge's Problems panel, on the public validator
page, at POST /api/validate, at publish, and again every time a match loads a
custom map: a direct launch, a replay, a host's map arriving at each guest, a
?mod= merge, and The Forge's Test Map all converge on one gate before anything
parses the bytes.
That gate never repairs a map and never rewrites a byte. Two clients repairing the same map slightly differently is a desync, which is worse than a refusal.
Which codes it blocks on, and the important caveat that this build ships the gate in reporting mode rather than enforcing mode, are on Troubleshooting by error code.
The public validate endpoint
POST /api/validate is open, unauthenticated and rate limited. It accepts a map,
a trigger script, an override set or a package, with the kind detected or forced.
Because it is open, it is also treated as hostile input in its own right: the body
is capped at 5 MB and the cap is enforced while the body streams, so an
over-length request is cancelled rather than buffered; an over-long compressed
terrain layer is refused before it is decompressed; and any exception inside the
validator collapses to a single "could not be read" result rather than escaping.
A map that fails validation is a 200 with valid: false. A 4xx means the
request itself was wrong.
What a map can do, that is worth knowing
Two things that are not boundaries, and are sometimes assumed to be.
A map sees the whole world, not one seat's view. Triggers run inside the
simulation, which holds the entire world state on every client, so
playerResource reads any seat's gold and unitsOfPlayer reads any seat's army.
There is no fog-of-war filtering in the trigger layer. This is not a leak: in
lockstep there is no per-client secret to leak, because every client already has
the whole world. But do not design on the assumption that a trigger can only see
its own seat, because it can see everything.
A map can change any shipped definition. That is what data overrides are for. A map that makes footmen cost one gold is doing exactly what the system allows. The protection for players is that the changes are declared, validated and listed before they join, not that they are impossible.
Reporting something
If you find a way for a map to do something this page says it cannot, that is a security issue rather than a bug report. Please send it privately rather than posting it, and include the smallest map that demonstrates it.
Related
- Troubleshooting by error code for every code and its class.
- Determinism for authors for the totality and clock rules, which are as much safety properties as correctness ones.
- Performance and limits for the resource boundaries.
- Mods and Custom units and models for the asset routes.