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:
| Line | What it tells you |
|---|---|
ended | The game over, its reason and its tick. no means the objective never fired |
fires | Every trigger that ran, with a count. A trigger missing from this line never fired at all |
vars | Every global, at the end. This is your test surface |
caps | Trigger budgets hit. Non-empty means the map stutters for real players |
broken | Runtime references that resolved to nothing |
asserts | PASS/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.
| Template | What it is | Shipped assertion |
|---|---|---|
melee-objective | Two seats, a real economy, hold the centre for 20s | victory, seat 0, tick 381 |
tower-defense | Three escalating waves down a lane; leaks cost lives | victory, 0 leaks, wave 3 |
arena | Two squads, respawns, first side to five kills | victory, score_0 >= 5 |
survival | Four escalating night raids from a random bearing | victory, raid >= 4 |
cinematic | A 27-second scripted opening, no player agency | victory, 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
periodicevent, 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.
unitEntersRegioncosts nothing when nothing happens; "is anything near the goal" costs a full scan forever groupCount,groupAtandgroupRandomare free on a group you already hold.forEachUnit+groupAtis 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.
- Randomness comes from
randandrandRealand nowhere else. Both always consume exactly one draw, even whenmin == max, because a conditional draw would advance the stream on one client and not another. - 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" (==). - There is no wall clock. Read the trigger
tickvalue orsos.time.tick(). In ordinary RTS lockstep, 20 ticks is one second; local profiles use their declared rate. - 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. - Stay inside the budgets. Overrunning deterministically means every client hangs identically and the desync detector reports nothing.
- Angles are radians everywhere in maths and geometry, but
createUnits.facingis in degrees. Multiply by 57.29577951308232.
Before calling it done
validategives 0 errors and 0 warningsplay --assertpasses every assertion, withcaps,brokenanderrorsall empty, and theendedline 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.