Map Editor Documentation

Build a custom game

Combine map JSON, visual triggers and sandboxed JavaScript or TypeScript; reuse public arcade controllers, validate the result and understand the current engine boundaries.

Start here · Page 46 of 49

A custom map is a game package: terrain, placed objects, player slots, rules, data overrides and optional gameplay logic. Creators can use JSON triggers, write sandboxed JavaScript or TypeScript, or combine both. The same map loader, validator and public scripting API serve official examples and community maps.

This supports many games beyond an RTS: tower defence, survival, hero arenas, escape games, dungeon encounters, lane races and puzzle or arcade variants. It does not mean every native engine facility is exposed, or that any conceivable game can be implemented without an engine extension. Check Current boundaries before building around a feature.

Choose the layer for each job

LayerUse it forWhere to work
Map JSON and data overridesTerrain, objects, teams, resources, rule switches, cloned units, models and balanceTerrain, Players and Data in the Forge; exported JSON and schemas
JSON triggersEvents, conditions, quests, HUD frames, camera, items, spells and victory logicTriggers (F6), the trigger reference and genre cookbook
JavaScript or TypeScriptCustom algorithms, state machines, timers, unit queries, damage/order hooks and scoringScripts (F11), the scripting guide and generated API declarations
Public native controllersThe rendering, input, collision and presentation of a scrolling shooter or pegboardScripts → Custom-game simulation and controller, plus the editable Aces/Drop packages

JSON is the package format, not a restriction that all gameplay must be authored as trigger nodes. A script entry keeps editable source, matching compiled JavaScript and an API level. The Forge handles compilation and validation; TypeScript types do not exist inside the running interpreter.

A trigger node name is not necessarily a JavaScript API method. Consult the generated API instead of guessing names. Scripts call supported trigger actions with sos.triggers.run(actions), sharing the existing interpreter, validation and budgets. There is no general trigger-to-JavaScript call action or direct JS trigger-variable API today.

Build and test a hybrid RTS map

  1. Create a map with valid start positions and declare its player slots. This example uses the existing seat id 1 in a two-seat map; use your own map's declared ids and valid coordinates.
  2. Turn standard victory and built-in AI off if your logic owns the encounter. Set starting units explicitly so an RTS base does not appear behind a game that does not need one.
  3. Add terrain, objects and data overrides as usual. Add JSON triggers for presentation or other events where the visual editor is convenient.
  4. In Scripts, add the following JavaScript. It stores state, uses a named timer, spawns units through the public API and calls a JSON trigger action for the message.
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.storage.lastWaveUnits = sos.units.spawn('goblin_brawler', 2, 1, { x: 20, y: 20 });
  sos.storage.messageAccepted = sos.triggers.run([
    { k: 'showText', players: null, text: 'Wave ' + sos.storage.waves }
  ]);
});

In the normal 20 Hz RTS profile, the timer repeats every five seconds. Add an explicit win and loss condition with triggers or sos.game.victory / sos.game.defeat; this short example only demonstrates the two logic layers working together.

  1. Use Test Map, then Trigger Debug → Scripts. Check that waves increase, units appear, the message is accepted and the script remains enabled. The trigger trace shows the JSON action's result.
  2. Test the outcome branches, save/load, and every player role. Syntax checks and schema validation do not prove gameplay correctness.
  3. Export the map or publish it through the normal custom-map flow. Validation and tooling covers the CLI and public endpoint; Publishing covers sharing it.

Keep durable values in sos.storage. Global variables and closures are recreated on save restoration. Register callbacks at source load; registrations are sealed before play. The onLoad callback is read-only, and mid-round saves are not available for local arcade controllers.

Recreate and remix Aces or Drop

Download a creator package and open it in the Forge with File → Import Map JSON…:

  • Aces creator package contains the scrolling-shooter controller, aircraft and weapon profiles, waves, level data and sandboxed kill scoring.
  • Drop creator package contains the pegboard controller, boards, missions, masters and sandboxed peg scoring.

Every Aces sector and Drop mission also has an exported example generated from the same builders under public/maps/. tools/maps/generate_arcade_examples.ts reproduces them from the repository. These use the public controller factory; external creators do not need private launch configuration.

Change the script to change the scoring or supported rules. Change the controller JSON to change authored content. Use Data to override models and definitions, then inspect the result in the asset browser and Test Map. Current arcade layout and wave authoring uses controller JSON; there is no dedicated visual pegboard or wave designer yet.

Repaint the menus

Every picture the Aces hangar and the Drop lobby draw is named in the package, so a copy can repaint any of it. Each field is optional: leave one out and the game draws its built-in picture.

FieldWhat it draws
meta.loadingScreenThe loading screen as the game opens. In Aces a sortie shows its level's art instead.
customGame.presentation.logoThe logo in the hangar's or the lobby's top bar.
customGame.presentation.backdropDrop: the painted hall behind the chosen hero.
customGame.presentation.daisDrop: where the hero stands in that painting. x and y are the platform's centre and height the hero's height, all as fractions of the picture (0 to 1); pitch is the painting's camera tilt in degrees (0 to 60).
customGame.presentation.factionBackdropsAces: the hangar behind each people's aircraft, keyed by race id (dwarf, human and so on). A shipped still with a -loop.mp4 and -loop.webm beside it plays the video.
a board's art and backdropDrop: the picture behind the board on its card (the board itself is drawn from its pegs, so every board previews itself) and the full painting behind the board screen.
a level's artAces: the sector's card in the mission list, and its loading screen.

Pictures follow the same rule as loading art: an image the game already ships (an /assets/ path ending in .png, .jpg, .webp or .avif) or an image you uploaded (a ugc: reference). Anything else, a web address included, is ignored and the built-in picture is drawn instead, so a map can never make its players' browsers fetch an address of its choosing. The official packages name all of these fields, so they are the reference to copy from.

The engine still supplies each controller's native rendering, controls, collision and presentation. They are reusable engine features, not copies of that native code running inside the sandbox. Both packages include their complete campaigns and use the same public campaign UI and player-save service. Aces includes its 22-sector arcade campaign, 100-mission adventure, workshop, module/aircraft unlocks and achievements; Drop includes all 15 boards, medal objectives and mastery ranks. Creator copies can change their own content and rules and keep independent progress.

Read sos.custom.state() for plain controller state and call sos.custom.command(name, value) for supported commands. The controller command table lists the available controls. There is no arbitrary method call or engine-object handle.

Player progression, unlocks and save codes

Open Scripts → Campaign, unlocks and player saves. Set your game title and unique save identity; Fork with independent saves gives a copy its own identity. The advanced package JSON contains the campaign catalogue and reward rules. A customGame package requires map format 5 and an explicit local simulation profile. The Forge stamps that version when exporting.

Use sos.storage for deterministic match state. Use sos.progress for creator-owned player data that survives rounds and browser restarts. There are eight save buckets per game, each limited to 32 KiB of finite JSON. For example:

sos.lifecycle.onInit(function () {
  sos.storage.hero = sos.progress.get('hero') || { level: 1, unlockedSpells: [] };
});
sos.events.onEvent(function (event) {
  if (event.name === 'sortie-ended' && event.data.outcome === 'victory') {
    sos.storage.hero.level += 1;
    sos.progress.set('hero', sos.storage.hero);
  }
});

Local saving works offline and signed out. With customGame.cloud: true, signed-in saves use the existing per-player map-save API. Published maps are bound to their actual publication id; a package cannot claim another published map’s namespace. progress.loadCloud(bucket) explicitly queues a cloud load and raises progress-cloud-result. Keep a local snapshot if you need to merge rather than replace it. Keep the package save version stable and version your own payload when implementing migrations.

progress.exportCode(bucket) and progress.importCode(bucket, code) provide portable, map-bound codes for buckets up to 16 KiB. The shared Player saves dialog lets players generate, copy and restore them. Codes reject accidental corruption, foreign maps/buckets and incompatible versions. The map’s key is public, so a client-side code is not proof of honest play and cannot protect a competitive server economy.

Creator databases and APIs

A creator may operate their own progression backend. Declare fixed HTTPS endpoints in customGame.endpoints, each with an id, URL and GET, POST or PUT method. Players see the endpoints and can allow access or play with it blocked. sos.network.request(endpointId, payload) returns a request id, or zero if refused. Read the creator-api-result event for { requestId, endpointId, ok, status, data }.

Requests run through the host bridge: scripts receive JSON, with no raw fetch, sockets, browser objects or filesystem access. Requests omit cookies and referrers, refuse redirects, cap request and response data at 32 KiB, allow two concurrent requests and six per minute, and time out after five seconds. Your server must allow browser CORS. Put player authentication in your application protocol; never embed a private database credential or server API secret in a downloadable map. Validate progression on your server if it must resist client tampering. These APIs are local-only and cannot introduce nondeterministic responses into an RTS lockstep match.

Timing and multiplayer

Normal RTS maps retain 20 Hz deterministic lockstep. JSON triggers and sandboxed scripts can participate in those matches, with matching map bytes, build and script runtime. Script storage, RNG, timers and deterministic circle-body state participate in save/load and checksums.

Creators can explicitly select local simulation at a supported rate of 20, 30, 60 or 120 Hz. The Forge and launch screen warn that these maps cannot join multiplayer or record deterministic RTS replays. Arcade controllers require this local profile. Higher rates request more CPU work per second and change tick-based cooldowns, timers and economy pacing.

For example, this map field chooses local 120 Hz simulation:

{ "simulation": { "mode": "local", "tickRate": 120 } }

This is a field fragment, not a complete playable map. Export from the Forge to preserve the rest of the package and its required format stamp. Timer periods use the declared tick rate: the hybrid example's 100-tick timer is five seconds at 20 Hz and less than one second at 120 Hz.

Local mode does not remove sandbox restrictions or enable online replication for a native controller. Determinism for authors explains the lockstep contract.

Current boundaries

NeedCurrent support
Custom gameplay codeSandboxed JS/TS through the exposed sos API and supported trigger actions; finite instruction and memory budgets
Circle physics for RTS entitiessos.physics.setCircle / getCircle: moving circles against static circles and rectangular bounds; world pixels and velocity per simulation tick
Pegboard rigid-body behaviourThe public local pegboard controller's Rapier-backed shapes, moving formations and bumpers
Arbitrary native physics, joints, general dynamic body pairs or custom native systemsNo general registration API; requires engine work
Custom presentationExposed HUD, camera and existing effects through trigger actions, plus model overrides and controller presentation; no raw renderer or DOM access
New spell mechanicsCombine exposed operations and hooks; new native spell-effect registration is not exposed
Runtime terrain editing, removing vision, granting research instantlyStill absent from the authored API; see the capability map
Browser/network/filesystem access, real clocks, dynamic imports or generated codeUnavailable inside map scripts
Campaigns, rewards, unlocks and player savesPublic package data and sos.progress; visual pegboard/wave designers remain unavailable

If a design needs something unexposed, request a public API or controller extension. Changing documentation cannot make that engine capability available. Engine hooks distinguishes authoring a map from extending the engine source.

References for external developers