Open Scripts in the Forge and choose Add script. JavaScript runs as written. TypeScript is compiled before saving; the map keeps both the editable source and the JavaScript that players run. Test Map runs the same sandbox used by published maps.
Keep persistent gameplay state in sos.storage. Global variables and captured closure state are recreated when loading a save; they are suitable for constants and temporary calculations. The storage object, seeded RNG and named timers are restored.
sos.physics.setCircle(id, options) attaches deterministic circle physics to an entity. Positions and velocities use world pixels; walls default to the map bounds. sos.physics.getCircle(id) reads its current position, velocity and contact count. This solver supports moving circles against static circles and walls; local pegboard maps use the public pegboard controller for richer shapes and moving formations.
sos.lifecycle.onInit(function () {
sos.storage.waves = 0;
sos.timers.repeat('wave', 100);
});
sos.lifecycle.onTimer(function (event) {
if (event.name !== 'wave') return;
sos.storage.waves += 1;
sos.units.spawn('goblin_wyvern_rider', 2, 1, { x: 20, y: 20 });
});Timers use simulation ticks. Ordinary RTS maps update at 20 Hz. A map can explicitly override RTS lockstep with a local-only simulation at 20, 30, 60 or 120 Hz. The editor and launch screen display a warning: these maps cannot enter multiplayer or record deterministic RTS replays. Changing RTS update rates also changes tick-based cooldowns and economy pacing.
The sandbox
Code runs inside a pinned QuickJS WebAssembly interpreter, with fixed memory, stack limits and instruction budgets. It cannot directly access the browser, DOM, network, filesystem, real-time clocks, dynamic imports or generated code. Local maps can use the permissioned sos.network.request bridge for declared creator APIs. A runaway script is interrupted by instruction count, not by how fast a player's computer is. Repeated faults disable the script and appear in the test-play debugger.
Use sos.rng for randomness and sos.time.tick() for time. Math.random() uses the same seeded per-script stream. Use sos.storage for persistent game state; ordinary JavaScript closure variables are not saved. Register callbacks at the script's top level so loading the source recreates them. The callback registry is sealed before the match starts.
The Scripts panel lists API calls and inserts their argument shapes. The complete author-facing declarations are available at sos-script-api.d.ts, and the machine-readable API is at script-api.json.
Working with the engine
Read and change units through sos.units, resources through sos.players, and outcomes through sos.game. World scans (ofPlayer, ofType, inRadius) return ascending entity ids and are charged against the script budget. Queries can refuse an oversized scan; check their result before iterating it.
sos.triggers.run(actions) validates and executes an array of ordinary trigger action nodes through the existing trigger interpreter. This gives scripts the trigger engine's UI, camera, items, abilities, effects and variable actions without a second implementation. Use script timers rather than a wait in an inline action list. The trigger reference gives the action shapes.
sos.hooks.onDamage(fn) can replace a damage amount by returning a finite number. sos.hooks.onOrder(fn) can cancel a command by returning 'cancel'. Hooks are bounded and cannot recursively re-enter the sandbox from an engine call made by another script callback.
sos.lifecycle.onLoad(fn) runs after persistent state is restored. It is read-only: engine mutations are refused and changing stored state disables the script. Use it for checking restored state, not resetting a match.
Remix the polished examples
The examples below are ordinary custom maps: download one and open it in the Forge with File → Import Map JSON…. Their controller definitions, timing profiles and scripts survive editing, export, Test Map and publishing. Copies include the complete public campaign framework and keep independent player progression. Their own missions, rewards, unlocks and workshop/mastery definitions are editable in the game package.
- Aces creator example: a scrolling shooter with editable aircraft, weapon profiles, formations, bosses and scoring code.
- Drop creator example: a pegboard with editable peg layouts, moving formations, bumpers, masters and scoring code.
The Custom-game simulation and controller section in Scripts exposes the map's controller JSON. The public scrolling-shooter and pegboard controllers supply rendering, input, collision and presentation. Aces and Drop use this same controller factory and loader.
Scripts read a plain snapshot with sos.custom.state() and send validated commands with sos.custom.command(name, value):
| Command | Value | Behaviour |
|---|---|---|
awardScore | A number | Add or subtract bounded score |
setRules | An object | Shooter: scrollSpeed, lives, bombs; pegboard: ballsRemaining, firingImpulse |
bullet | {x, y, vx, vy, damage, player, color} | Add a shooter bullet; coordinates are tiles, velocity is tiles per second, color is six-digit hex |
aim | An angle in radians | Aim the pegboard cannon |
fire | null | Fire a pegboard stone |
restart | null | Restart the current round |
victory / defeat | null | End the round with a script-defined result |
sos.events.onEvent(fn) receives simulation events such as spell-cast, item-used and entity-died, and controller events such as enemy-killed, peg-hit, round-start and sortie-ended. The shipped example scripts use these events to calculate scores. Change that code and the game changes.
Pegboard physics uses the same Rapier engine as Drop and is local-only. The separate sos.physics.setCircle(unit, options) API attaches the bounded deterministic circle solver to an RTS entity: it supports circles against static circles and rectangle bounds, without rigid-body stacking, joints or dynamic body pairs. Its velocities and gravity use world pixels per RTS tick.
Debug and publish
Choose Test Map, open Trigger Debug, then select Scripts. It shows each script's gas, errors, strikes, timers, stored state and disabled status. Fix runtime errors before publishing; syntax checks alone cannot prove that a script does what you intended.
Save and publish normally. The validator checks source/compiled agreement and forbidden syntax; players validate the map again when loading. A missing interpreter, unsupported API level or script that fails during loading refuses the match with an error instead of silently removing its logic.