Map Editor Documentation

The Genre Cookbook

Eight custom-game shapes as recipes: the rules to set, the triggers to add, a trigger fragment checked against a real map, and one command per recipe that plays the map and asserts the result.

Genres · Page 35 of 49

These recipes cover common custom-game shapes built with JSON triggers. They are starting points, not a list of the only genres the engine allows. Use sandboxed JavaScript or TypeScript for custom algorithms and reuse public controllers for shooters or pegboards. Build a custom game explains how those layers fit together.

Each recipe has four parts:

  • The rules, which you set once in the Players module (F8).
  • The triggers, as steps you click in the Triggers module (F6).
  • The JSON, so you can read what the editor writes. Every fragment here was checked against the map it belongs to.
  • The check, one command that plays the map with no window and no player and asserts the result.

The checks assume a checkout of the repository. If you are working in the browser only, the recipes still apply; skip the commands and use Ctrl+F9.

The four ingredients every recipe shares

1. Turn standard victory off. In Players (F8), under Rules. The engine's last-team-standing check is right for a skirmish and wrong for every genre below, and on a one-seat map it declares victory on tick 1. See Your First Win Condition.

2. Turn no starting units on, unless the genre has a normal economy. It stops the engine handing every seat a town hall and workers behind your back.

3. Give the rule a number. A variable that your triggers move and your ending reads: lives, wave, score_0, hold_ticks. Without one you can assert that the match ended but not that it ended for the right reason.

4. Prefer an event to a poll. unitEntersRegion costs nothing until something enters. "Is anything near the goal" asks twenty times a second forever. When you must poll, use Every N ticks at 20, which is once a second and twenty times cheaper. Group scans are charged per unit walked, against 500,000 expression evaluations a tick.

A fifth, which is not a rule but a habit: set the time of day in a mapInit trigger, with speed at 0. progress runs from 0 at dawn to 1 at the next dawn, and speed is a multiplier, so 0 is what pins the lighting for the whole match. Omitting speed means 1, not "leave it alone". It is a simulation change, not decoration: night costs sight radius.

How to check a fragment

Every JSON block below is one trigger, not a whole map, and a bare trigger cannot be validated on its own. Validate it against the map it belongs to, so its regions, variables and map-defined units are in scope:

npx tsx tools/validate.ts my-trigger.json --against my-map.json
PASS my-trigger.json [Trigger fragment, against my-map.json] 0 error(s), 0 warning(s)

Leave --against off and the same file reports this instead, which is not a problem with your trigger:

FAIL my-trigger.json [Unknown format] 1 error(s), 0 warning(s)
  ERROR unknown-format $: Could not tell what this is: not a map, trigger script, defOverrides set or mod package

1. Skirmish with one objective

The shape. A normal RTS match, economy and all, plus one authored goal. The smallest possible answer to "a skirmish, but with a twist".

The rules. Standard victory off. Starting units on, because you want the normal economy. Two or more seats.

The triggers.

  1. Draw a region over the contested ground and name it shrine.
  2. Add an int variable hold_ticks, initial value 0.
  3. + Trigger Count shrine control. Event Every N ticks, every 20. Condition: the count of seat 0's units in shrine is greater than 0. Action: Add to variable hold_ticks, delta 20.
  4. + Trigger Objective complete, Run once. Event Every N ticks, every 20. Condition hold_ticks ≥ 400. Actions: Show text, then Victory player 0.

The JSON.

{
  "id": "t_hold",
  "name": "Count shrine control",
  "events": [{ "k": "periodic", "everyTicks": 20 }],
  "conditions": [
    {
      "k": "gt",
      "a": {
        "k": "groupCount",
        "group": { "k": "unitsInRegion", "region": "shrine", "filter": { "players": [0] } }
      },
      "b": 0
    }
  ],
  "actions": [{ "k": "addVar", "name": "hold_ticks", "delta": 20 }]
}

The check.

npx tsx packages/modkit/src/cli.ts play packages/modkit/templates/melee-objective/map.json \
  --ticks 900 --seed 1 \
  --assert 'state=victory' --assert 'winner=0' --assert 'var:hold_ticks>=400'
ended    victory — winner seat 0 at tick 381 (19s) [trigger]
vars     hold_ticks=400
fires    Count shrine control ×20, Setup ×1, Objective complete ×1
caps     0
broken   0
errors   0
RESULT   OK

Start from packages/modkit/templates/melee-objective/.


2. Tower defence

The shape. Creeps walk a fixed lane. You build towers beside it. Leaks cost lives, and running out of lives loses.

The rules. Standard victory off. No starting units on. Set allowedBuildings to the towers and nothing else, so the build menu is the genre.

The triggers.

  1. Two regions: creep_spawn at one end of the lane, goal at the other.
  2. Variables: lives (int, 10), wave (int, 0), leaks (int, 0), and a unitGroup for the creeps in flight.
  3. One trigger per wave, on an At tick event, each calling Create units into that group and then Order them attack_move to the goal.
  4. + Trigger A creep reached the goal. Event Unit enters a region, region goal. Actions: subtract a life, add a leak, kill the creep, play a cue.
  5. + Trigger Defeat, Run once, on Every N ticks, condition lives ≤ 0.
  6. + Trigger All waves held, Run once, condition wave equals the last wave and nothing is still alive.

Killing the leaker matters. A creep that reaches the goal and stays there fires nothing more, but it is still walking around your base.

The JSON.

{
  "id": "t_leak",
  "name": "A creep reached the goal",
  "events": [{ "k": "unitEntersRegion", "region": "goal" }],
  "actions": [
    { "k": "addVar", "name": "lives", "delta": -1 },
    { "k": "addVar", "name": "leaks", "delta": 1 },
    { "k": "killUnit", "unit": { "k": "eventUnit" } },
    { "k": "playSound", "soundId": "human_under_attack", "players": null }
  ]
}

The check.

npx tsx packages/modkit/src/cli.ts play packages/modkit/templates/tower-defense/map.json \
  --ticks 3000 --seed 1 \
  --assert 'state=victory' --assert 'winner=0' \
  --assert 'var:lives>0' --assert 'var:wave==3' --assert 'var:leaks==0'
ended    victory — winner seat 0 at tick 2401 (120s) [trigger]
vars     lives=10, wave=3, leaks=0, incoming=[56,57,58,59,60,61,62,63]
fires    Setup ×1, Wave 1 ×1, Wave 2 ×1, Wave 3 ×1, All waves held ×1
caps     0
broken   0
errors   0
RESULT   OK

That template wins headlessly because it pre-places what a player would have built. A tower defence with nothing pre-placed loses by default in a headless run, because nobody is building anything, and that is the correct result: it proves the map is losable. The shipped tower defence showcase does exactly that.

Start from packages/modkit/templates/tower-defense/.


3. Wave survival

The shape. Hold a camp against escalating raids that arrive from anywhere. The difference from tower defence is the geometry: no lane, so the pressure is all around you.

The rules. Standard victory off. No starting units on. Whatever defensive buildings the genre wants in allowedBuildings.

The triggers.

  1. One region, camp, over the ground you are defending.
  2. Variables: raid (int), bearing (real), and a unitGroup for the raiders.
  3. One trigger per raid, on At tick. Each one draws a random bearing, spawns on a ring at that bearing, and orders the raiders inward.
  4. A Survived trigger, and an Overrun trigger that can actually be reached.

The ring is the trick, and it replaces a table of hand-written coordinates. randReal draws an angle between 0 and 2π, and polarOffset turns a centre point plus that angle plus a distance into a spawn tile. Both need map format 3, which the editor stamps for you.

Angles are in radians everywhere in the maths and geometry nodes. The one exception is createUnits.facing, which is degrees. A literal above 6.28 in an angle slot is flagged, because it is almost always degrees by mistake.

The JSON.

{
  "id": "t_raid_5",
  "name": "Raid 5",
  "events": [{ "k": "atTick", "tick": 3200 }],
  "actions": [
    { "k": "setVar", "name": "raid", "value": 5 },
    { "k": "setVar", "name": "bearing", "value": { "k": "randReal", "min": 0, "max": 6.283185307179586 } },
    {
      "k": "createUnits",
      "unitTypeId": "goblin_brawler",
      "count": 10,
      "player": 1,
      "at": {
        "k": "polarOffset",
        "p": { "k": "regionCenter", "region": "camp" },
        "angle": { "k": "var", "name": "bearing" },
        "distance": 15
      },
      "intoVar": "raiders"
    },
    {
      "k": "order",
      "units": { "k": "var", "name": "raiders" },
      "order": "attack_move",
      "to": { "k": "regionCenter", "region": "camp" }
    }
  ]
}

intoVar does not declare the variable for you. Add raiders to the variables tab as a unitGroup first. (forEachUnit's varName does auto-declare, which is exactly the inconsistency that catches people.)

The check.

npx tsx packages/modkit/src/cli.ts play packages/modkit/templates/survival/map.json \
  --ticks 3400 --seed 1 \
  --assert 'ended=true' --assert 'state=victory' --assert 'var:raid>=4'
ended    victory — winner seat 0 at tick 2741 (137s) [trigger]
vars     raid=4, bearing=5.123973739079094, raiders=[61,62,63,64,65,66,67,68]
fires    Setup ×1, Raid 1 ×1, Raid 2 ×1, Raid 3 ×1, Raid 4 ×1, Dawn ×1
caps     0
broken   0
errors   0
RESULT   OK

Because this recipe draws random numbers, --seed genuinely changes it. Run three seeds and see. A map with no random node is identical on every seed by construction, and that is not a bug.

Start from packages/modkit/templates/survival/. See it at scale: Survival showcase.


4. Arena and deathmatch

The shape. Two or more sides fight in a ring. First to N kills wins. The fallen come back, so the match cannot stall.

The rules. Standard victory off. No starting units on.

The triggers.

  1. Two regions, one pit per side.
  2. A score variable per side, and a unitGroup per side.
  3. + Trigger Score a kill on Unit dies, with no filter. Inside it, read the killer's owner and add to that side's score.
  4. + Trigger Reinforce both sides, periodic, so neither side runs out.
  5. + Trigger Five kills, periodic, condition on the score, Victory.

The scoring trigger is where the two event units matter. eventUnit is the unit that died; eventOtherUnit is the killer. Reading the killer's seat with unitProp and its owner property is how the kill is attributed.

The JSON.

{
  "id": "t_score",
  "name": "Score a kill",
  "events": [{ "k": "unitDies" }],
  "actions": [
    {
      "k": "if",
      "cond": { "k": "eq", "a": { "k": "unitProp", "unit": { "k": "eventOtherUnit" }, "prop": "owner" }, "b": 0 },
      "then": [{ "k": "addVar", "name": "score_0", "delta": 1 }],
      "else": [{ "k": "addVar", "name": "score_1", "delta": 1 }]
    }
  ]
}

The check.

npx tsx packages/modkit/src/cli.ts play packages/modkit/templates/arena/map.json \
  --ticks 3000 --seed 1 \
  --assert 'ended=true' --assert 'state=victory' --assert 'var:score_0>=5'
ended    victory — winner seat 0 at tick 461 (23s) [trigger]
vars     score_0=5, score_1=3, humans=1, west=[2,3,4,5], east=[6,7,8,9], reinf_w=[20,21], reinf_e=[22,23]
fires    Score a kill ×8, Reinforce both sides ×4, Setup ×1, Send in the squads ×1, Five kills ×1
caps     0
broken   0
errors   0
RESULT   OK

A warning specific to this shape. A symmetric map has two endings, and it is very easy to write the second one so that it cannot fire. A victory declared on a seat nobody is watching from fires and does not end the match. Pair it: Victory on the winning seat and Defeat on the losing human seat, which plays back as state=defeat, winner=none. Then play the branch your assertions do not cover. The shipped arena template had exactly this bug in an else its assertions never entered.

Start from packages/modkit/templates/arena/. See it at scale: Hero arena showcase.


5. Cinematic opening

The shape. No player agency at all. Fades, camera moves, dialogue, a time of day, and then an ending. Usually the first thirty seconds of something else.

The rules. Standard victory off. No starting units on. Fog off, so the camera can see what it is pointing at.

The triggers. One trigger, on mapInit, with Wait actions between the beats. That is what a cutscene actually is: a sequence, authored top to bottom.

  1. setTimeOfDay with speed 0.
  2. fadeScreen out, cameraLock, then Wait.
  3. cameraPan to the first subject, fadeScreen in, a line of text, a cue.
  4. Wait, pan, line, Wait, pan, line.
  5. cameraUnlock, and something that ends the match.

Camera, fades and text are per-client presentation. They must never decide anything the simulation depends on. Increment a beat variable alongside them if you want to assert the sequence ran.

The JSON.

{
  "id": "t_scene_2",
  "name": "Second beat",
  "runOnce": true,
  "events": [{ "k": "mapInit" }],
  "actions": [
    { "k": "cameraLock", "players": null },
    { "k": "fadeScreen", "players": null, "type": "out", "colorHex": "#000000", "durationTicks": 1 },
    { "k": "wait", "ticks": 20 },
    { "k": "cameraPan", "players": null, "to": { "k": "point", "x": 8, "y": 8 }, "durationTicks": 0 },
    { "k": "fadeScreen", "players": null, "type": "in", "durationTicks": 40 },
    { "k": "showText", "players": null, "text": "Karag Dhun, the hour before light.", "durationTicks": 120 },
    { "k": "setVar", "name": "beat", "value": 1 },
    { "k": "wait", "ticks": 120 },
    { "k": "cameraUnlock", "players": null }
  ]
}

The check.

npx tsx packages/modkit/src/cli.ts play packages/modkit/templates/cinematic/map.json \
  --ticks 1200 --seed 1 \
  --assert 'ended=true' --assert 'state=victory' --assert 'var:beat==5'
ended    victory — winner seat 0 at tick 541 (27s) [trigger]
vars     beat=5
fires    Opening scene ×1
caps     0
broken   0
errors   0
RESULT   OK

A map with no player agency still has to end. If it does not, every assertion you write reads still running, and you will spend an hour looking for a broken trigger that is working perfectly.

Start from packages/modkit/templates/cinematic/. For a scene inside a longer match, the Cutscenes module has a scrubbable timeline and is a better tool than a wall of waits.


6. Escape, against a clock

The shape. No economy and no combat you can win. A clock, hunters that follow you, and several routes out with different prices.

The rules. Standard victory off. No starting units on. allowedBuildings empty, because there is nothing to build. The hunter seat set passive, because its behaviour comes from your triggers and not from the RTS AI.

The triggers. A periodic clock that counts down and loses at zero. One unitEntersRegion trigger per objective, each Run once. One unitEntersRegion trigger on the exit, conditional on the objectives being done. And a chase trigger.

The chase is the interesting part, and the escape example does it with a hashtable: a hunter's entity id maps to the unit it is currently chasing. Every fifteen ticks it does a closestUnit scan, compares the answer with what it remembered, and issues an order only when the target changed. Re-ordering every tick costs more and looks worse, because the unit keeps restarting its path.

The check. This one is a generator with its design assertions built in, so the check is the generator itself:

npx tsx tools/examples/build.ts --check

It re-derives every route length and every threat figure and fails if one no longer holds. The full walkthrough, with the measured route prices, is Vault of Ash.


7. Siege, holding one thing

The shape. Waves attack one structure. You defend it, repair it, and decide where to spend your attention. The structure's remaining health is the score.

The rules. Standard victory off. No starting units on. allowedBuildings limited to the defences. Attacker seat passive.

The triggers. A periodic wave clock that spawns and orders. A unitEntersRegion trigger on a repair building that takes gold with a negative addResource and gives health with setUnitHp. A unitDies trigger paying bounty to the killer. And an ending that reads the structure's alive or hp through unitProp and entityByAid.

entityByAid is worth learning early. Give an important building an authored id in the editor, and a trigger can find it by that id forever, however the map changes around it.

The check. npx tsx tools/examples/build.ts --check, then the walkthrough: Siege of Ember Gate.


8. Lane race

The shape. Two lanes, two kings. Both sides get the same creeps. Whatever leaks past your defences hits your king, and whatever leaks past theirs hits theirs. You never fight the other player directly; you out-build them.

The rules. Standard victory off. No starting units on. allowedBuildings set to your defence pieces plus an income building. Both opponent seats passive.

The triggers. A periodic round clock that pays income and then spawns both lanes. A unitEntersRegion trigger per king that applies the leak. A buildingComplete trigger that notices what was built and adjusts income. And an ending that compares the two kings.

The design lesson is worth more than the triggers: the map is interesting only if the income building is a real choice. Pay for defence now, or pay for income that pays for defence later. If one is always right, the map has no decision in it. That is measurable, and the example measures it.

The check. npx tsx tools/examples/build.ts --check, then the walkthrough: Ironvale Legion.


Two rules you can author now

Both of these were listed here as gaps, and both have since landed. They are worth knowing early, because between them they are how you cap a game.

  • The population cap. setPopCap writes a seat's cap outright. Leave value out and it clears the authored cap instead, handing the seat back to the one its buildings earn, so a scripted cap can be temporary: squeeze a seat for a phase, then let it breathe.
  • Which technologies a seat may research. setTechAllowed allows or forbids one upgrade for one seat, mid-match. Forbidding it greys the button out and refuses the order where the command is executed, so a client that asks anyway spends nothing. Re-allowing it is the same node with allowed true.

The full list of what is and is not here, with the nearest alternative for each gap, is the Capability map, and it is checked against the live node catalogue in both directions, so it cannot quietly go out of date.

Where to go next

  • The walkthroughs under Genres in the sidebar take one shipped map each and read it end to end.
  • Triggers and the Trigger reference are the full node language.
  • Performance and limits is the page to read before a map with a lot of units in it.
  • Testing covers Test Map, Trigger Debug and the rest of what the editor can tell you.