Map Editor Documentation

Walkthrough: Tower Defense

A read-through of the shipped tower defence showcase: its waves, its lives counter, the two triggers that can end it, and why a headless run of it loses.

Genres · Page 36 of 49

Canyon Holdout Tower Defense is a one-seat defence map: goblin waves walk from a portal in the west to a gate in the east, and you build towers in the open field between them. The question it asks, every wave, is the same one: is the path you have built long enough yet?

Play it

Canyon Holdout Tower Defense

What it is made of

PieceValue
Size48, biome wasteland
Seat 0user, dwarf, team 0, starting 350 gold and 150 lumber
Seat 1computer, goblin, team 1, marked passive. It owns the creeps and never builds
RulesdisableAi, noStartingUnits on; standardVictory off
BuildableSix tower ids only, via allowedBuildings
PlacedOne dwarf_town_hall named Mountain Hold, tagged player_base, 2000 max HP, plus three miners
Triggers9
Variables7
Regions6
Overrides6 buildings and 4 units in defOverrides

The six buildable ids include two the map defines itself, custom_spire_tower and custom_flak_tower, both cloned from dwarf_tower in the overrides block. The four creep types are clones too, from goblin units.

Only two regions do any work at runtime: spawn, which creeps are created at, and base_goal, the 4x6 gate they walk to. build_zone is a 38x38 square handed to seat 0 once. The three waypoint regions are map furniture.

How it works

TriggerWhen it firesWhat it does
t_init Map InitializationmapInit, onceCreates three counters (Lives, Wave, Next Wave), shows the opening text, and grants seat 0 the build_zone region with setBuildableRegion
t_wave_timer Wave Cooldown Timerperiodic, every 20 ticksDecrements next_wave_timer while it is above zero and refreshes the counter
t_wave_1 Wave 1: Goblin ScoutsatTick 600, onceSets wave to 1, adds 8 to creeps_alive, createUnits 8 scouts at the centre of spawn, then order them to move to the centre of base_goal
t_wave_2 Wave 2: Armored RaidersatTick 1200, onceThe same shape, 10 brawlers
t_wave_3 Wave 3: Air GargoylesatTick 1800, onceThe same shape, 8 fliers
t_wave_4 Wave 4: Boss JuggernautatTick 2400, onceTwo createUnits and two order calls: one boss and six brawlers
t_leak Creep Leaks BaseunitEntersRegion on base_goal, filtered to seat 0's enemiesTakes a life, drops creeps_alive, resets the Hold's HP with setUnitHp, removes the leaker, then tests both endings
t_base_destroyed Base DestroyedunitDies filtered to seat 0's dwarf_town_hall, oncedefeat on player 0
t_creep_dies Creep Dies / BountyunitDies filtered to seat 0's enemiesPays seat 0 15 gold with addResource, drops creeps_alive, then tests the win

Three things make the genre work, and none of them are obvious.

The creeps are ordered to move, not to attack-move. A plain move is what makes a maze a maze: the creeps ignore your towers and walk the shortest legal path, so the only thing you control is how long that path is. The generator says why it was changed, and the reason is worth reading: attack-moving creeps sieged the maze instead. The boss outranges a watchtower, so it flattened the towers from outside their reach and then shot the Hold from nine tiles away, and the map was lost through Base Destroyed with every life intact. In a mazing map, leaking is supposed to be the only way to lose.

The base's health bar is the lives counter. t_leak does not damage the Hold. It subtracts a life and then calls setUnitHp with lives multiplied by 100, so 20 lives is 2000 HP and the health bar over the building is a display of the variable. That is a display trick, not a rule: the rule lives entirely in lives.

The win check is written twice, on purpose. Both t_leak and t_creep_dies end with the same guard: wave at least 4, creeps_alive at most 0, and lives above 0. A creep leaves the field in one of two ways, by dying or by leaking, and the final wave's last creep can do either. With the check only on the death trigger, a match whose last creep leaked sat at zero creeps forever and never ended.

{
  "id": "t_leak",
  "name": "Creep Leaks Base",
  "events": [
    {
      "k": "unitEntersRegion",
      "region": "base_goal",
      "filter": { "enemyOf": { "k": "int", "v": 0 } }
    }
  ],
  "actions": [
    { "k": "addVar", "name": "lives", "delta": { "k": "int", "v": -1 } },
    { "k": "addVar", "name": "creeps_alive", "delta": { "k": "int", "v": -1 } },
    {
      "k": "setUnitHp",
      "unit": { "k": "entityByAid", "aid": "player_base" },
      "value": {
        "k": "mul",
        "a": { "k": "var", "name": "lives" },
        "b": { "k": "int", "v": 100 }
      }
    },
    { "k": "removeUnit", "unit": { "k": "eventUnit" } }
  ]
}

How the match ends

The win is a victory on player 0, and it is issued from whichever of t_leak or t_creep_dies resolves the last creep of the last wave. Its condition is wave at least 4 and creeps_alive at most 0 and lives above 0.

The loss has two independent routes, both defeat on player 0. t_leak issues it when lives reaches 0 or below. t_base_destroyed issues it if the Mountain Hold dies, which is a safety net: the generator's own note says that in a mazing map the only loss path should be leaks.

There is one seat a player can watch from, so both outcomes go on seat 0 and nothing needs pairing. On a map with more than one human seat you would pair them; see Your First Win Condition.

Run it yourself

npx tsx packages/modkit/src/cli.ts play public/maps/tower-defense-showcase.json \
  --ticks 6000 --seed 1
map      public/maps/tower-defense-showcase.json (seed 1)
seats    0:dwarf/t0 (human, idle unless --drive) ✗  1:goblin/t1
ended    defeat — winner none at tick 2056 (103s) [trigger]
vars     lives=-1, wave=3, creeps_alive=5, wave_max=4, next_wave_timer=18, current_creep=0, last_spawned=[24,25,26,27,28,29,30,31]
commands 0 issued by --drive
fires    Wave Cooldown Timer ×102, Creep Leaks Base ×21, Map Initialization ×1, Wave 1: Goblin Scouts ×1, Wave 2: Armored Raiders ×1, Wave 3: Air Gargoyles ×1
caps     0
broken   0
errors   0
RESULT   OK

It loses, and that is the point. A headless run has nobody at the keyboard, so not one tower gets built, every creep walks straight into the gate, and lives runs out during wave 3 at tick 2056. This is the cheapest proof a defence map can give you that it is losable. A map that wins with nobody playing it has no defence in it at all.

Run it short and the same map tells you nothing:

npx tsx packages/modkit/src/cli.ts play public/maps/tower-defense-showcase.json \
  --ticks 600 --seed 1
ended    no — still running after 600 ticks (30s)
vars     lives=20, wave=0, creeps_alive=0, wave_max=4, next_wave_timer=1, current_creep=0, last_spawned=[]
fires    Wave Cooldown Timer ×29, Map Initialization ×1
caps     0
broken   0
errors   0

Wave 1 is scheduled for tick 600, so 600 ticks stops one tick before anything happens. "Still running" reads exactly like a dead map. Check the ended line and raise --ticks before you debug anything.

What to take from it

  • Give the objective a number, and let the art follow it. lives is the rule. The health bar is setUnitHp fed from that variable, so the two can never disagree.
  • Put the win check on every path that can reach it. Both t_leak and t_creep_dies carry the same guard, because a creep can leave the field by dying or by leaking.
  • Order creeps with move when the map is a maze. Attack-move turns a tower defence into a siege, and the loss arrives through the wrong trigger.
  • Gate building with setBuildableRegion, not with terrain. One call in t_init grants seat 0 the build_zone region, and allowedBuildings in the rules block narrows the palette to six towers.
  • Keep the goal region off the building it protects. base_goal sits in front of the Hold, not over it. A region centred on a 4x4 footprint has an unwalkable centre, so the arriving crowd settles around the building and half of it never enters the region, which means it never counts as a leak.

The same genre at eight seats

wonder-defense-8p is the co-op version: size 80, eight user seats all on team 0, eight spawn portals and eight lanes converging on a Wonder in the middle, ten waves instead of four. It reuses the same six tower ids.

Two details change once seats can be empty. t_init reads playerAlive for each of the eight seats and records the answer in active_0 through active_7, and t_victory then issues victory only for the seats that answered yes. The buildable grant is still setBuildableRegion, called once per seat, all eight pointing at the one shared build_zone.

Changing it

This map is generated. Never hand-edit public/maps/tower-defense-showcase.json. Edit the generator and re-run it:

npx tsx tools/generate_td_map.ts

tools/generate_td_map.ts holds the terrain, the seats, the cloned towers and creeps, and all nine triggers. It validates the biome, the trigger script and the overrides, and it runs a pathfinding check across the open field before it writes anything, so a change that walls the creeps out fails there rather than in a match.

The 8-seat variant is tools/generate_8p_wonder_defense.ts, run the same way.

Where to go next