Map Editor Documentation

Your First Win Condition

Turning a map into a game: the built-in victory presets, when you need a trigger instead, the counter-and-outcome pattern every genre uses, and the seat rule that decides whether your victory actually ends the match.

Start here · Page 31 of 49

You have a map. This page turns it into a game: something that can be won and can be lost, and that says so.

The learning path so far:

  1. Your First Map: terrain, gold, a region, one trigger.
  2. Your first win condition (this page).
  3. Your First Custom Unit.
  4. Your First Mod.
  5. Publish and Iterate.

It takes about twenty minutes, and you will finish with a map that ends properly whichever way the match goes.

First: you may not need a trigger at all

Open the Players module (F8) and look at the Rules block. There is a victory preset picker there, and it already covers a lot of ground:

PresetWhat wins the match
ConquestLast team standing, and nothing else
StandardConquest, or a held Wonder, or every Relic banked
WonderConquest, or holding a completed Wonder for the hold length
RelicsConquest, or banking and holding every Relic on the map
RegicideConquest, plus: a team whose King dies loses outright
King of the HillConquest, or holding the central neutral Monument
ScoreA time limit, and the highest-scoring living team wins
DominationCapture and hold control points to build score
Capture the FlagCarry the neutral flag home

Some of these need the map to contain the thing they are about. King of the Hill needs a central Monument, Capture the Flag needs a flag site, Relics need relics. If a preset describes your idea, set it, place what it needs, and you are done. No triggers.

The rest of this page is for the other case: the rule you want is yours, and no preset says it.

Step 1: turn off the standard win check

This is the one that catches everyone, so do it before anything else.

In Players (F8), under Rules, find Standard victory and turn it off.

Standard victory is the engine's own last-team-standing check. It is on by default, and it is exactly right for a normal skirmish. It is wrong for everything else, because it runs all the time and does not know about your rule. On a one-seat map it is worse than wrong: "one player left alive" is true on tick 1, so the match declares victory before the player has moved. The validator refuses to publish a one-seat map with standard victory still on, and that rule exists because the failure is instant, total and completely unexplained the first time you see it.

Turning it off means the engine will never end the match. From here on, ending the match is your job, and if you never write the trigger the match never ends.

While you are in that block, two neighbours matter:

  • No starting units. Turn it on when you do not want the engine handing every seat a town hall and workers behind your back. A tower defence or an escape map wants this on. A skirmish with an objective wants it off.
  • Min and max players. Set the real range. This is what the lobby will let people choose.

Step 2: give the rule a number

Nearly every win condition in this engine is the same shape: a number, a rule that moves it, and a rule that reads it.

Lives that count down. Waves survived. Seconds held. Kills banked. Crystals delivered. Pick the number your game is about, and make it a variable.

In Triggers (F6), open the variables tab and add one:

  • Name: lives
  • Type: int
  • Initial value: 20

That variable is not just bookkeeping. It is how you will test the map later, because an automated run can read it back and tell you the objective made progress rather than just that the match ended. A win condition with no number behind it is very hard to check.

Step 3: the trigger that moves the number

Add a trigger that changes the variable when the thing happens. For a defence map, that is "something reached the goal":

  1. + Trigger, name it Leak.
  2. Events: Unit enters a region, region goal.
  3. Actions: Add to variable, lives, delta -1.
  4. Actions: Kill unit, so the leaker does not sit in the region forever.

Leave Run once unticked. This one should fire every time.

Two habits worth forming now, because they are the difference between a map that runs well and one that stutters:

  • Prefer an event to a poll. "A unit entered this region" costs nothing until it happens. "Is anything standing near the goal" asks the question twenty times a second forever.
  • If you must poll, poll on a timer. An Every N ticks event at 20 ticks runs once a second. A player cannot tell it from a per-tick check, and it costs a twentieth as much. Group scans are charged per unit walked, against a budget of 500,000 expression evaluations per tick, so a scan on a busy map is not free.

Step 4: the trigger that ends the match

Add a second trigger that reads the number and calls it:

  1. + Trigger, name it Defeat, and tick Run once.
  2. Events: Every N ticks, every 20.
  3. Conditions: A ≤ B, with A the variable lives and B the number 0.
  4. Actions: Show text to everyone, The gate has fallen.
  5. Actions: Defeat, player 0.

Run once matters here. Without it the trigger keeps firing every second after the game is over.

Then write the winning side too, as its own trigger: Every N ticks, condition wave ≥ the last wave, actions Show text and Victory, player 0.

Step 5: the rule about seats, which is not obvious

A victory or defeat action resolves for the seat the player is watching from. Outside multiplayer, a victory declared on any other seat fires, does nothing visible, and the match carries on.

It is not an error. Nothing is logged. The trigger runs to the end, the "Objective complete" line appears in the debug panel, and the match simply does not stop.

Measured on the melee-objective starter template, which wins by holding the centre shrine. The shipped map ends with victory on seat 0:

ended    victory — winner seat 0 at tick 381 (19s) [trigger]
fires    Count shrine control ×20, Setup ×1, Objective complete ×1

The identical map with that one action changed to seat 1, a computer seat, and nothing else touched:

ended    no — still running after 900 ticks (45s)
fires    Count shrine control ×45, Setup ×1, Objective complete ×1

Same trigger, fired once, in both runs. One match ended and one did not.

So when the other side is supposed to win, do not declare victory on the other side and stop. Declare the losing human seat defeated as well:

  • Victory, player 1
  • Defeat, player 0

Played back, that reads as a defeat with no winner, which is what the player watching from seat 0 should see:

ended    defeat — winner none at tick 381 (19s) [trigger]

Pair every outcome both ways. It costs one extra action and it is the difference between a symmetric map with two endings and one with half of them missing.

Step 6: play both endings

Press Ctrl+F9 to test. Then do the part everybody skips: play the branch you did not write the test for.

Let something through on purpose and watch the defeat. Then hold and watch the victory. A conditional outcome that has never executed is a guess, and the starter templates themselves shipped with one such branch that could not win until somebody played it.

If nothing happens, press Ctrl+Shift+F9 for Trigger Debug, which lists every trigger that fired and every condition that failed. The usual answer is a region drawn somewhere other than where you walked.

Step 7: check it without playing it

The map editor's Test Map is the honest check, but it needs you at the keyboard. For the check you can repeat after every edit, run the map headlessly from a checkout of the repository:

npx tsx packages/modkit/src/cli.ts play my-map.json \
  --ticks 3000 --seed 1 \
  --assert 'state=victory' --assert 'winner=0' --assert 'var:lives>0'

The output tells you four things at once:

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

The fires line is the fastest diagnostic in the toolkit. A trigger that never fired prints nothing there, which is usually the whole answer.

Three things will mislead you the first time:

  • A headless run has no player in it. Human seats sit idle unless you drive them, so a build-to-survive map loses by default. That is correct, and it is worth having: it proves the map is losable. The shipped tower defence showcase does exactly this, ending in defeat at tick 2056 with lives at -1, because nobody built a tower. To assert a win, either pre-place what a player would have built, or drive the seat.
  • One tick short reads like a dead trigger. --ticks 600 on the same showcase prints still running after 600 ticks, which looks identical to a broken map. Read the ended line before you believe anything else, and raise --ticks before you start debugging.
  • A non-empty caps line means real stutter. Overrunning a budget disables the offending trigger and reports it. It does not fail silently and with no name attached, but it does mean your map is spending more than its share.

The whole thing as JSON

If you would rather read it than click it, this is the trigger half of a lives-based defence, and it is the same document the editor writes:

{
  "version": 1,
  "vars": [
    { "name": "lives", "type": "int", "init": 20 }
  ],
  "regions": [
    { "id": "goal", "name": "Goal", "x": 14, "y": 2, "w": 4, "h": 4 }
  ],
  "triggers": [
    {
      "id": "t_leak",
      "name": "Leak",
      "events": [{ "k": "unitEntersRegion", "region": "goal" }],
      "actions": [
        { "k": "addVar", "name": "lives", "delta": -1 },
        { "k": "killUnit", "unit": { "k": "eventUnit" } }
      ]
    },
    {
      "id": "t_defeat",
      "name": "Defeat",
      "runOnce": true,
      "events": [{ "k": "periodic", "everyTicks": 20 }],
      "conditions": [
        { "k": "le", "a": { "k": "var", "name": "lives" }, "b": 0 }
      ],
      "actions": [
        { "k": "showText", "players": null, "text": "The gate has fallen." },
        { "k": "defeat", "player": 0 }
      ]
    }
  ]
}

Note le, not lte. The comparison nodes are eq, ne, lt, le, gt and ge, and there is no gte or lte. If you write one, the validator will suggest gt, which is > where you meant >=. That suggestion validates cleanly and shows up much later as a condition that fires a tick early, or never. Read what a suggestion is, not just that there was one.

To check a trigger on its own without pasting it back into the map, validate the fragment against the map, so region ids and map-defined clones are in scope:

npx tsx tools/validate.ts my-trigger.json --against my-map.json

Without --against, every region id and every unit your map defines reads as unknown and the report is noise.

Common first mistakes

What you seeWhat it usually is
Victory on tick 1Standard victory still on, with one competing seat
The trigger fires and the match continuesThe outcome is on a seat nobody is watching from. Pair it with a defeat on the watched seat
Nothing fires at allThe region is not where you think it is. Trigger Debug (Ctrl+Shift+F9) will say
The message repeats foreverRun once is unticked on the ending trigger
Headless says "still running"--ticks is too low, or the map genuinely never ends. Check the ended line first
The map can only be lostThe winning branch was never played. Play it

Where to go next

  • Triggers is the full reference for events, conditions, variables, functions and outcomes.
  • Players & Teams covers seats, teams, the rules block and one-player maps in detail.
  • The Genre Cookbook has a worked recipe for each of the common shapes, with a command per recipe.
  • Performance and limits explains every budget and what happens when you cross one.

Next: Your First Custom Unit.