Map Editor Documentation

Security model

What a custom map may and may not do to the machine that loads it, why each boundary exists, and what is checked where.

Advanced · Page 29 of 49

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:

RouteSoundsModels
A .sosmod packageA data: URI up to 256 KB, or https://Same
A data-override setdata:, https://, /api/…, or localhost during developmentdata:, 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.