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.jsonPASS 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 package1. 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.
- Draw a region over the contested ground and name it
shrine. - Add an
intvariablehold_ticks, initial value0. - + Trigger
Count shrine control. Event Every N ticks, every20. Condition: the count of seat 0's units inshrineis greater than0. Action: Add to variablehold_ticks, delta20. - + Trigger
Objective complete, Run once. Event Every N ticks, every20. Conditionhold_ticks≥400. Actions: Show text, then Victory player0.
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 OKStart 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.
- Two regions:
creep_spawnat one end of the lane,goalat the other. - Variables:
lives(int, 10),wave(int, 0),leaks(int, 0), and aunitGroupfor the creeps in flight. - One trigger per wave, on an At tick event, each calling Create units
into that group and then Order them
attack_moveto the goal. - + Trigger
A creep reached the goal. Event Unit enters a region, regiongoal. Actions: subtract a life, add a leak, kill the creep, play a cue. - + Trigger
Defeat, Run once, on Every N ticks, conditionlives≤0. - + Trigger
All waves held, Run once, conditionwaveequals 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 OKThat 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.
- One region,
camp, over the ground you are defending. - Variables:
raid(int),bearing(real), and aunitGroupfor the raiders. - 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.
- 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 OKBecause 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.
- Two regions, one pit per side.
- A score variable per side, and a
unitGroupper side. - + Trigger
Score a killon Unit dies, with no filter. Inside it, read the killer's owner and add to that side's score. - + Trigger
Reinforce both sides, periodic, so neither side runs out. - + 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 OKA 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.
setTimeOfDaywithspeed0.fadeScreenout,cameraLock, then Wait.cameraPanto the first subject,fadeScreenin, a line of text, a cue.- Wait, pan, line, Wait, pan, line.
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 OKA 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 --checkIt 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.
setPopCapwrites a seat's cap outright. Leavevalueout 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.
setTechAllowedallows 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 withallowedtrue.
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.