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
What it is made of
| Piece | Value |
|---|---|
| Size | 48, biome wasteland |
| Seat 0 | user, dwarf, team 0, starting 350 gold and 150 lumber |
| Seat 1 | computer, goblin, team 1, marked passive. It owns the creeps and never builds |
| Rules | disableAi, noStartingUnits on; standardVictory off |
| Buildable | Six tower ids only, via allowedBuildings |
| Placed | One dwarf_town_hall named Mountain Hold, tagged player_base, 2000 max HP, plus three miners |
| Triggers | 9 |
| Variables | 7 |
| Regions | 6 |
| Overrides | 6 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
| Trigger | When it fires | What it does |
|---|---|---|
t_init Map Initialization | mapInit, once | Creates 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 Timer | periodic, every 20 ticks | Decrements next_wave_timer while it is above zero and refreshes the counter |
t_wave_1 Wave 1: Goblin Scouts | atTick 600, once | Sets 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 Raiders | atTick 1200, once | The same shape, 10 brawlers |
t_wave_3 Wave 3: Air Gargoyles | atTick 1800, once | The same shape, 8 fliers |
t_wave_4 Wave 4: Boss Juggernaut | atTick 2400, once | Two createUnits and two order calls: one boss and six brawlers |
t_leak Creep Leaks Base | unitEntersRegion on base_goal, filtered to seat 0's enemies | Takes a life, drops creeps_alive, resets the Hold's HP with setUnitHp, removes the leaker, then tests both endings |
t_base_destroyed Base Destroyed | unitDies filtered to seat 0's dwarf_town_hall, once | defeat on player 0 |
t_creep_dies Creep Dies / Bounty | unitDies filtered to seat 0's enemies | Pays 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 1map 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 OKIt 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 1ended 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 0Wave 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.
livesis the rule. The health bar issetUnitHpfed from that variable, so the two can never disagree. - Put the win check on every path that can reach it. Both
t_leakandt_creep_diescarry the same guard, because a creep can leave the field by dying or by leaking. - Order creeps with
movewhen 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 int_initgrants seat 0 thebuild_zoneregion, andallowedBuildingsin the rules block narrows the palette to six towers. - Keep the goal region off the building it protects.
base_goalsits 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.tstools/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
- Walkthrough: Castle Fight builds its army from buildings instead of from a wave timer.
- Walkthrough: Hero Arena is the free-for-all shape, with a custom event between scoring and winning.
- Your First Win Condition is the short version of the counter-and-outcome pattern this map is built on.
- Triggers and the Trigger Reference cover every node named above.
- Data Overrides explains the cloned towers and creeps.