Map Editor Documentation

The AI Agent Toolkit

The agent-facing half of AI authoring: the validate → play → assert loop, the starter templates, the four engine facts no schema will tell you, and what an agent gets wrong on its first attempt.

AI & automation · Page 30 of 49

Working with AI Assistants is written for you, the author. This page is written for the thing doing the typing. Hand it to a coding agent, or read it yourself to know what to expect.

The premise is simple and it is the whole design: an agent cannot see your map, cannot hear your sounds, and cannot play your game. Everything below exists to replace one of those senses with a command.

The loop

# 1. VALIDATE — is it well formed?
npx tsx tools/validate.ts my-map.json
 
#    Iterating on ONE trigger? Validate the fragment against the map:
npx tsx tools/validate.ts my-trigger.json --against my-map.json
 
# 2. PLAY — does it do anything?
npx tsx packages/modkit/src/cli.ts play my-map.json --ticks 3000
 
# 3. ASSERT — does it do the RIGHT thing?
npx tsx packages/modkit/src/cli.ts play my-map.json --ticks 3000 \
  --assert 'state=victory' --assert 'winner=0' --assert 'var:lives>0'

Step 1 is not the bar. A map with zero validation errors can be a blank field where nothing happens: a wave trigger whose tick never arrives, a victory condition that cannot be met, a region nothing ever enters. All of those are schema-perfect. Step 2 is where they show up.

play's output is designed to be read by a machine that has never seen the map:

LineWhat it tells you
endedThe game over, its reason and its tick. no means the objective never fired
firesEvery trigger that ran, with a count. A trigger missing from this line never fired at all
varsEvery global, at the end. This is your test surface
capsTrigger budgets hit. Non-empty means the map stutters for real players
brokenRuntime references that resolved to nothing
assertsPASS/FAIL per assertion, with the actual value

Add --json for a machine-readable summary, --seed N to vary the run, and --drive seat=cmds.json to give a human seat something to do.

--against, and why a fragment validated alone is noise

A trigger fragment on its own has no idea your map's regions, variables or defOverrides clones exist. Validate it alone and every id it correctly uses is reported as unknown, which buries the real errors. --against splices the fragment into the map's own script, keeps the map's vars, regions, functions, hashtables, defOverrides, sounds and scenes available, and reports problems in your fragment only, with paths rebased onto $.

It accepts a whole TriggerScript, a single trigger, an array of triggers, a single action, or an array of actions. It cannot be combined with --kind.

Start from a template that already plays

packages/modkit/templates/ ships five genre skeletons. Every one validates with zero errors and zero warnings and plays to a real game over with a passing assertion, so an agent's first command produces a green result rather than a puzzle.

TemplateWhat it isShipped assertion
melee-objectiveTwo seats, a real economy, hold the centre for 20svictory, seat 0, tick 381
tower-defenseThree escalating waves down a lane; leaks cost livesvictory, 0 leaks, wave 3
arenaTwo squads, respawns, first side to five killsvictory, score_0 >= 5
survivalFour escalating night raids from a random bearingvictory, raid >= 4
cinematicA 27-second scripted opening, no player agencyvictory, beat == 5

Each directory carries an AGENTS.md with its genre's numbers and the numbers' reasoning, plus a CLAUDE.md pointer. The shared packages/modkit/templates/AGENTS.md is the one to read first. Regenerate the maps with npx tsx packages/modkit/templates/generate.ts; never hand-edit a generated map JSON.

"Copy Map as AI Context" gives an agent the map it cannot see

File → Copy Map as AI Context in The Forge emits a budgeted brief:

  • a terrain histogram by tile kind
  • elevation levels in use, and the ramp list. This matters more than it sounds: ground movement needs equal cliff levels, or a one-level step with a ramp on both tiles, so a map with levels and no ramps is a set of sealed plateaus and the export says so
  • reachability between every pair of starts, and whether a break is water (needs transports) or a cliff (needs a ramp)
  • the seat roster with races, teams, controllers and starting resources
  • the rules block, with a named warning for each rule that is missing
  • authored scenes, by id
  • regions, placed entities, and the existing script
  • every trigger node with its exact fields, derived from the same tables the validator uses, and a [v3] marker on any node that raises the format stamp
  • unit and building ids scoped to the races this map seats

It is capped at 88,000 characters (roughly 22,000 tokens). That cap is not decoration. Before it existed, one shipped showcase map produced a 351,889-character export, around 88,000 tokens, most of a context window, almost all of it one pretty-printed trigger script. An agent handed that has no room left to answer.

Deliberately left out, because it is noise rather than context: the asset manifest (about 98,000 hashed paths, when what an agent needs is sound ids), per-tile terrain (a 512x512 map is 262,144 tiles), the other races' id lists, and anything an agent cannot act on. Anything trimmed is named in the export's final section along with the command that retrieves it, and a trigger script too large to include degrades to a structural outline rather than truncated JSON. An agent handed half an object will try to complete it.

Four facts no schema will tell you

Time of day: speed: 0 is what pins it

{ "k": "setTimeOfDay", "progress": 0.78, "speed": 0 }

progress runs 0 (dawn) to 1 (next dawn). speed is a multiplier and 0 pauses the cycle, which is the only way to hold a lighting mood for a whole match. Omitting speed means 1, not "leave it alone".

Set it from a mapInit trigger. The editor's Environment panel does not reach a running match, and setTimeOfDay is not restored by save/load, so re-assert it from a periodic trigger if it matters. It is simulation state rather than presentation: night costs sight radius, and the phase is hashed by the desync detector.

Sound: all 3,691 shipped effects are nameable, and the first play is silent

{ "k": "playSound", "soundId": "dwarf_garrison_bell", "players": null }

The id is the bare basename, so dwarf_ack_1 and never voice/dwarf_ack_1.mp3; a / is rejected outright. players: null means everyone.

The part that costs people a cue: a match preloads roughly 950 of the 3,691, and any other id is silent on its first playSound and plays from the second call onwards. So name your dramatic cues once in a mapInit trigger to warm them, or the stinger you care about is the one that gets missed. There is no music node; /assets/music/** is outside the sound namespace.

Silence is the most common reason a generated map feels dead. A template should demonstrate sound, not merely permit it, and all five of ours do.

Budgets: a group scan is charged per entity walked

EXPR_EVALS_PER_TICK is 500,000, and it is a wall-clock budget in disguise: one charged unit is about 60 ns, so 500,000 is roughly 30 ms, or one and a half ticks at 20 Hz.

unitsInRegion, unitsOfPlayer, unitsOfType, unitsInRadius, closestUnit, groupSortBy, groupPickN and groupFilter are each one node that does O(live entities) of work, and each is charged accordingly. One scan of a 2,400-unit map costs 2,400. The same scan inside a forEachUnit over that pool costs 5.8 million and trips the cap.

Before that charge existed, a published map spent 2,369 ms inside a single tick, 47 tick budgets, while passing every check. So:

  • scan on a periodic event, not every tick. Once a second is a twentieth of the cost and almost always indistinguishable to a player
  • prefer an event to a poll. unitEntersRegion costs nothing when nothing happens; "is anything near the goal" costs a full scan forever
  • groupCount, groupAt and groupRandom are free on a group you already hold. forEachUnit + groupAt is the cheap idiom; forEachUnit { addUnitToGroup } is O(n²) and is charged as such

Overrunning does not fail silently. The trigger is disabled and the fault is reported, and play prints it on the caps line.

Format versions: the version stamp is protecting you

37 nodes declare that they need map format 3. Any map using even one of them must declare "v": 3, and the validator reports format-version-understated as an error if it does not.

That is deliberate. Adding a node to the language is additive, so an older client would not crash on a newer map. It would read your node, not recognise it, and run the map with that line missing. A tower defence whose wave-spawn action is silently skipped is not a broken map, it is an empty one, and nothing would tell the player why. The version gate makes the client refuse it with a message a player can act on instead.

Do not author v by hand. Re-saving from the editor stamps the right number.

Writing sandboxed code

Use the generated script API and TypeScript declarations. The trigger catalogue describes JSON nodes, not JavaScript methods: do not guess a sos method from a trigger name. Use sos.triggers.run for supported trigger actions without direct JS methods. Build a custom game includes a hybrid example.

A script entry carries editable source, matching compiled JavaScript and the declared API level. Use the Forge or compileMapScripts from the repository’s scripting compiler to compile and validate it. Keep durable state in sos.storage, register callbacks before play and check runtime diagnostics. The Aces and Drop example maps include the same public controller data and scoring scripts their official launches use. Local arcade profiles cannot be played in multiplayer.

What an agent gets wrong on its first attempt

These five templates were written the way an agent writes: read the catalogue, use the plausible name. The first pass produced 52 validation errors across five maps, and almost none of them were typos. They are the list worth memorising.

1. Guessed node names. There is no gte or lte; the operators are eq ne lt le gt ge. This one used to be the worst trap on the page: the validator answered "did you mean gt" for gte, and gt is > where you wrote >=, so taking the suggestion bought you an off-by-one that validated perfectly and fired a tick late, or never. It now answers ge and tells you in the same message not to take gt. The lesson survives the fix: check the operator you were handed against the one you meant.

2. Guessed ids. goblin_grunt does not exist, and the nearest name is goblin_peon — a worker, so a raider wave made of peasants with a clean report. The validator now names the role of whatever it suggests, and when that role cannot fight it lists the same race's fighters, which is how you get to goblin_brawler. Read what the suggestion IS, not just that there was one — and look the id up rather than guessing in the first place (see trap 16).

3. Guessed field names. polarOffset takes p, not from. cameraShake takes intensity, not magnitude. There is no unitOwner node at all: every unit property comes through unitProp with a prop enum.

4. Assuming intoVar auto-declares. forEachUnit's loop variable does, and its documentation says so, which is exactly why this is an easy mistake: createUnits.intoVar does not. Nine of the fifty-two errors were this one. The target must be a unitGroup you already declared in vars; the node's own help now says so, and so does callFunction.intoVar's and every other node that writes to a variable by name.

5. Copying an old format stamp. See format versions above. Props and scripts require modern formats; a local timing profile or arcade controller requires format 4. Re-export or let the serializer stamp the required version.

6. Placing starting units to the right of the town hall. A hall is 4x4 from its top-left tile, so a hall at (8,10) owns 8 to 11 across and 10 to 13 down. A worker at (11,10) is inside it, and one of the two will fail to spawn and vanish with no error. The validator reports overlapping-footprint, which reads like housekeeping and is not.

7. Asserting a victory in a genre that loses by default. play puts no player in the match. A tower defence therefore loses every time unless you pre-place what a player would have built. That is not a bug to work around: it is the losability proof, and a build-to-survive map that cannot lose that way has waves that are too weak.

8. A tick budget one tick short. Our tower-defence template wins at tick 2401. Run it with --ticks 2400 and play reports "still running", which reads exactly like a dead trigger. Read the ended line before believing an assertion failed for the reason it claims.

9. Declaring victory on a count rather than a state. "All waves cleared" needs the creeps dead, not merely spawned. Checking wave == 3 announces victory while eight raiders are still walking.

10. Scoring a kill from the killer. A unit can die to a tower, to attrition, to a trigger, or to nothing at all, and then there is no killer and every one of those deaths scores for seat 0. Derive the score from the victim's owner: that is total, and unitProp(eventUnit, 'owner') always has an answer.

11. Forgetting standardVictory: false. A one-seat map declares victory on tick 1 without it, because "one living team remains" is trivially true.

12. Forgetting cameraUnlock. The scene ends and the player still has no camera, with no error anywhere. Lock and unlock in the same trigger.

13. Declaring victory for the side the player is not watching from. It fires and does not end the match. Measured: a map whose last action was {"k":"victory","player":1} on a computer seat ran its trigger to completion and was still running 660 ticks later, with errors 0 and broken 0; the same map with player: 0 ended immediately. Pair it with defeat on the losing human seat, and the result then reads state=defeat, winner=none — winner=1 is not reachable. The arena template itself shipped with this bug in an else branch its own assertions never entered, which is how an agent came to copy it.

14. Trusting the validator on the terrain layer. See below. It is the one part of a map where zero errors means nothing at all.

15. Reading --seed as a difficulty dial. The seed only moves what your own rand / randReal nodes draw. A map with no random node is byte-identical on every seed, and that is correct rather than broken.

16. Guessing an id instead of looking it up. There is no longer an excuse: catalog --kind units --ids-only prints the ids, one per line, in text mode as well as with --json. It used to print units 248 and nothing else, which is exactly the moment an agent starts inventing names, and goblin_grunt is the one it invents. Look it up, and read the role while you are there.

The terrain layer is not validated

terrain is run-length encoded: rows separated by |, runs inside a row by ,, each run <count>x<terrainId>.

"terrain": "32x0|32x0|13x0,6x1,13x0|…"

Nothing checks it. Measured on an otherwise-valid map, all four of these pass with 0 errors and 0 warnings and then play to a normal result: a row ten tiles short of the declared meta.size; ten rows missing entirely; ; instead of , between runs; a terrain id of 99. A separator the parser does not recognise silently falls back to an all-grass map, so an encoding mistake presents as a design problem.

Ids: 0 grass, 1 dirt; 2–8 are water, trees, gold, stone and rock, and all of them block ground movement. catalog cannot enumerate terrain ids.

Two consequences. Paint terrain in The Forge, or generate it — the templates' generate.ts builds a tile grid and calls rleEncodeRow per row, which cannot get the width wrong. And if you do hand-write it, verify it behaviourally: flood a region with id 2, spawn units there, and check the resulting unitGroup is empty in play.

The same asymmetry applies to footprints. overlapping-footprint is an error, not a warning, but it covers entity-versus-entity only: a 3x3 gold mine sitting inside a 4x4 town hall validates clean, and one of the two entities then fails to spawn silently.

Determinism, in six rules

The match is lockstep. Every client runs the same simulation and they must stay bit-identical.

  1. Randomness comes from rand and randReal and nowhere else. Both always consume exactly one draw, even when min == max, because a conditional draw would advance the stream on one client and not another.
  2. Never branch simulation on the local player. "If I am seat 1" is the classic desync. Text, camera, fades and sound are per-client presentation; anything touching units, resources or victory is shared. Seats are not 0 and 1: a lobby seats up to eight, creeps sit above the playable range, observers have no seat. Prefer "is enemy" (!=) to "is mine" (==).
  3. There is no wall clock. Read the trigger tick value or sos.time.tick(). In ordinary RTS lockstep, 20 ticks is one second; local profiles use their declared rate.
  4. Order is authored order. Groups are id-sorted, with one exception that bites: groupSortBy's result loses its order the moment it is stored in a variable, because variables are canonicalised on write. Use it inline.
  5. Stay inside the budgets. Overrunning deterministically means every client hangs identically and the desync detector reports nothing.
  6. Angles are radians everywhere in maths and geometry, but createUnits.facing is in degrees. Multiply by 57.29577951308232.

Before calling it done

  • validate gives 0 errors and 0 warnings
  • play --assert passes every assertion, with caps, broken and errors all empty, and the ended line is what you intended
  • the map is losable, proven by breaking your own defence and watching it lose
  • the map is winnable, proven the same way with the defence in place
  • audio exists, and the cues that matter are warmed
  • the lighting is deliberate
  • if the map is meant to vary by seed, three seeds actually differ. For a puzzle or escape map, zero variance is the correct answer

Next: Working with AI Assistants for the author's side, and Validation & Tooling for every validator issue code.