Map Editor Documentation

Walkthrough: Deep Delve

A read-through of the hand-authored horde showcase: four spawn regions, waves that scale by arithmetic, and a win condition that is one atTick node with no condition attached.

Genres · Page 40 of 46

Deep Delve: Survivors is one hero alone in a cave while the swarm grows around them. It asks one question for fifteen minutes, which is whether you are still alive, and it never asks anything else.

Play it

Deep Delve: Survivors

What it is made of

PieceWhat the file says
Map48 tiles square, deep_cave biome, mapType of dungeon
CapabilitiesThe file retains legacy render-target metadata; The Forge and custom matches now use 3D
Seat 0user, dwarf, team 0. Starts on 100 gold and owns both placed entities
Seat 1computer, ratmen, team 1. Every spawned horror belongs to it
RulesdisableAi: true, standardVictory: false, noStartingUnits: true, allowedBuildings empty
Placed2 entities, both seat 0's: the Sanctum building with author id delve_sanctum, and one delve_hero with author id player_hero
Script9 triggers, 5 variables, 5 regions
Data6 unit overrides, and no buildings block at all

allowedBuildings is an empty list, not an absent field. Combined with noStartingUnits, that is how the map guarantees the player never gets a base: there is nothing to place, nothing is handed out, and seat 0 starts and ends with the two things the map placed by hand.

Both of those carry an author id, and nothing reads either one. There is no entityByAid anywhere in this map's triggers. That is not a fault, it is just worth knowing about author ids: they cost nothing to set, and they do nothing at all until a trigger looks one up.

The capabilities block in this older map records the render target it was authored for. It is preserved as legacy metadata: The Forge and custom matches now use 3D. The four regions named spawn_north, spawn_east, spawn_south and spawn_west ring the arena, and arena_center sits in the middle of it.

How it works

TriggerFires onWhat it does
t_initmapInit, run onceSets the leaderboard title and four rows, shows the briefing dialog, prints the opening line
t_close_briefingdialogButton for delve_briefing, run onceOne hideDialog
t_clockperiodic, every 20 ticksAdds 1 to time_survived, redraws the timer row, counts surge_countdown down while it is above 0
t_wave_surgeperiodic, every 600 ticksRaises wave, resets surge_countdown to 30, announces the surge, and runs four createUnits calls, one per spawn region
t_boss_encounteratTick 6000, run onceSpawns one delve_broodmother at the centre of spawn_north and orders it in
t_boss_finalatTick 12000, run onceSpawns one delve_titan at the centre of spawn_south and orders it in
t_creep_killunitDies filtered to enemyOf seat 0Adds 1 to kills, pays 15 gold, redraws the kills row
t_hero_diesunitDies filtered to unitTypeIds of delve_hero and ofPlayer 0, run oncedefeat for seat 0, and a line of text
t_victory_conditionatTick 18000, run oncevictory for seat 0, and a line of text

The wave is one periodic trigger and four spawn regions. Every 600 ticks, t_wave_surge calls createUnits at randomPointInRegion for each of the four edge regions, then follows each call with an order of attack_move at regionCenter of arena_center. The counts are expressions rather than constants: the north and east calls each ask for 6 + wave * 2 gnawers, the south call for 4 + wave skitterers, and the west call for 2 + wave / 2 sentries, truncated. Nothing anywhere holds a table of waves. The escalation is arithmetic on one variable, which is why the trigger is thirteen actions long instead of ninety.

randomPointInRegion, rather than regionCenter, is what makes a swarm read as a swarm. A dozen units spawned at one point arrive as a single clump and get stuck on each other; the same dozen spread across the 36 by 3 north band arrive along a front. The two bosses do use regionCenter, because one unit has nothing to spread out from.

The bosses are atTick, not milestones. t_boss_encounter at tick 6000 and t_boss_final at tick 12000 are five and ten minutes in, on a clock that never pauses. They do not check the wave number, or the kill count, or whether the previous boss is dead. Two atTick triggers marked run once is the entire mechanism, and it is worth preferring whenever a beat should land at a known time rather than after a known amount of progress.

How the match ends

The win is t_victory_condition, and it has no condition at all. The trigger has an atTick event set to tick 18000, no conditions array, and a victory for seat 0. Reaching tick 18000 is the win, because reaching tick 18000 means the defeat trigger never fired:

{
  "id": "t_victory_condition",
  "name": "Victory Condition at 15 Minutes",
  "runOnce": true,
  "events": [{ "k": "atTick", "tick": 18000 }],
  "actions": [{ "k": "victory", "player": { "k": "int", "v": 0 } }]
}

At 20 ticks per second, tick 18000 is 15 minutes. time_survived exists on the leaderboard so the player can watch the number climb, but no rule reads it.

The loss is t_hero_dies, which is the other half of the same idea. Its event is unitDies with a filter of unitTypeIds set to ["delve_hero"] and ofPlayer set to 0, and its only outcome action is defeat for seat 0. The filter is on the event rather than in a condition, so the trigger body never runs for any of the hundreds of other deaths on this map.

Both outcomes land on seat 0, which is the seat the player is watching from, so both stop the match. Seat 1 is never told anything, and on a map with exactly one human seat that is enough. Add a second human seat and it stops being enough, for the reason Your First Win Condition sets out.

Run it yourself

npx tsx packages/modkit/src/cli.ts play public/maps/deep-delve-showcase.json \
  --ticks 2400 --seed 1
map      public/maps/deep-delve-showcase.json (seed 1)
seats    0:dwarf/t0 (human, idle unless --drive)  1:ratmen/t1
ended    no — still running after 2400 ticks (120s)
vars     time_survived=120, wave=3, kills=62, surge_countdown=1, spawned=[127,128,129]
commands 0 issued by --drive
fires    Survival Clock ×120, Bounty & Kills ×62, Wave Surge Progression ×3, Initialization ×1
caps     0
broken   0
errors   0

Two minutes in, three surges have landed and 62 horrors are dead, which is the hero defending itself with no player at the keyboard. The fires line is the useful part: the one-second clock at 120 matches time_survived=120, the surge trigger at 3 matches wave=3, and Bounty & Kills at 62 matches kills=62, which is the filtered death event matching on every horror that died.

Validation is worth running too, because it finds something real here:

npx tsx packages/modkit/src/cli.ts validate public/maps/deep-delve-showcase.json
PASS public/maps/deep-delve-showcase.json [Full Map (SerializedMap)] 0 error(s), 1 warning(s)
  WARNING perf-uncapped-periodic-spawn $.triggers.triggers[3].actions[5]: Trigger "Wave Surge Progression" fires every 600 ticks (30s) and creates units with nothing to stop it: it has no conditions, does not run once, never disables itself, and the spawn is not inside an if. The unit count rises for the whole match. Add a condition that caps the population, set the trigger to run once, or have it disable itself when the wave is done.

That warning is correct, and this map lives with it: the hard stop at tick 18000 means the unit count cannot rise forever, because the match is over first. A map without a fixed end should take the advice.

One honest gap, unlike the survival, tower defence, castle fight and hero arena showcases: this map has no dedicated outcome test in the repository. Nothing drives a seat to the 15 minute win or to the hero's death and asserts what happened. The endings are readable in the JSON and have been played, but they are not pinned.

What to take from it

  • A survive-for-N-minutes rule needs no counter. One atTick trigger with no conditions, marked run once, holding a victory. Writing a variable, a periodic that increments it and a ge comparison gets the same result with three more things to get wrong.
  • Filter the event, not the body. unitDies takes unitTypeIds, ofPlayer and enemyOf. t_hero_dies and t_creep_kill are both one filtered event with a short body, and neither pays for the deaths it does not care about.
  • Spread a wave with randomPointInRegion. Four thin edge regions, each spawn followed by an order of attack_move at regionCenter, gives pressure from every side with no pathing setup at all.
  • Scale with arithmetic on the wave number. add, mul, div and trunc over one wave variable replaces a hand-written table and never runs out of waves.
  • Keep legacy capability metadata intact. Older maps may carry render-target declarations in meta.capabilities; leave those fields in place when editing an existing map unless you have deliberately updated its presentation.

Changing it

This map has no generator. Nothing in tools/ writes public/maps/deep-delve-showcase.json; the only references anywhere in the repository are test fixtures, the featured list and the perf fixture list. It is hand-authored, pretty-printed JSON, which is why it reads so differently from the generated showcases.

So you have two routes, and the first is the better one:

  • Open it in The Forge. File → Import from AI / JSON…, paste the file, and you get the terrain painter, the trigger editor and Test Map (Ctrl+F9).
  • Edit the JSON. Fine for a trigger, risky for terrain. The terrain layers are run-length encoded strings, and a hand-edited one can be the wrong shape and still load, because the decoder is lenient on purpose. Both this map and the tanks showcase once shipped a broken one: this file stored 47 rows for a 48 tile map, so its last row loaded as grass in a solid rock cave. The sweep in src/game/map/mapValidation.terrainEncoding.test.ts now runs every shipped map through the same rule, and the codes to know are layer-row-count, layer-row-width and layer-malformed-run.

Either way, validate before you play:

npx tsx packages/modkit/src/cli.ts validate public/maps/deep-delve-showcase.json

Where to go next