Map Editor Documentation

Walkthrough: Escape Maze

A read-through of the Vault of Ash escape map: three routes that charge three different currencies, trigger-authored chase AI kept in a hashtable, and a clock tuned against measured route lengths.

Genres · Page 42 of 49

Vault of Ash — Escape is a two-delver escape map with a 100 second collapse clock and a vault seal that wants two of three sigils. Each of the three routes to a sigil charges a different currency, and the map is built so that taking all three is a gamble rather than a shopping list.

Play it

Vault of Ash

What it is made of

PieceValue
Size64, biome cave, mapType standard
Map format"v": 3
Seats 0 and 1user, dwarf, team 0, 0 starting gold
Seat 2computer, undead, team 1, passive
RulesstandardVictory off; noStartingUnits and disableAi on; allowedBuildings empty; fog normal
Placed4 unowned buildings (the seal and three shrines) and 8 units: 2 delvers, 6 stalkers
Triggers, variables12 and 15
Regions8: hall, ford, warren, stair, sigil_a, sigil_b, sigil_c, vault
Functions2: take_sigil, end_run
Hashtables2: aggro, post

The format stamp is not a choice. A node carries the map format it needs and the map is stamped with the newest one it uses. This map calls closestUnit and formatTicks, both of which declare format 3, so it serializes as "v": 3 where the other two genre examples stay at the baseline "v": 2. An older build then refuses it at the version gate instead of loading it with those nodes silently missing.

Every fighting id is a clone in defOverrides: vault_delver from dwarf_royal_guard (420 HP, speed 3.2), vault_stalker from creep_wolf (58 HP, damage 20) and vault_reaper from creep_troll_brawler.

The decision

The seal wants two sigils. There are three, and the three spurs off the trunk corridor charge three different things:

RouteRound trip, in walkable stepsWhat it costs
Ember Ford40Hit points. 12 tiles of shallow lava, burning at 16 HP a second
Stalker Warren38Risk. A 6-strong pack with trigger-authored chase AI
Long Stair120Time, and nothing else. No hazard at all

The Ford and the Warren are almost the same length, deliberately: with the time cost equal, the choice between them is purely blood against risk. The third sigil pays a +20 second bonus, and the walk to collect it costs more than the bonus returns, which is what makes going for all three a gamble rather than free value.

How it works

TriggerWhen it firesWhat it does
t_init Map InitializationmapInit, onceSets the time of day, makes the seal invulnerable with setUnitInvulnerable, writes every stalker's starting tile into the post hashtable, seeds aggro, and raises the counters, leaderboard, a createUIFrame clock bar and three quests
t_clock The collapse clockperiodic, every 20 ticks, while not overCounts seconds_left down by one, recounts delvers_alive with groupCount, refreshes the readouts, wakes the reapers on schedule, and calls end_run when the clock or the delvers run out
t_sigil_a / t_sigil_b / t_sigil_cunitEntersRegion on that sigil region, filtered to tags: ["delver"], run onceremoveUnit on the shrine, a ping, then callFunction into take_sigil
t_escape Escape, the winunitEntersRegion on vault, filtered to tags: ["delver"], run onceGuarded on seal_open. A fadeScreen, then callFunction into end_run with true
t_ford / t_warren / t_stairunitEntersRegion on that route, run onceOne line of warning the first time a delver commits to a route
t_chase Stalker chase AIperiodic, every 15 ticks, while not overThe pack. Described below
t_deaths DeathsunitDiesBranches on eventString to count a dead delver or a dead hunter
t_low_hp Near-miss feedbackperiodic, every 20 ticks, offsetTicks: 7Walks the delvers and warns when one is nearly gone

The technique worth stealing is t_chase. It is a working chase AI written entirely in triggers, and it rests on one rule: re-order a stalker only when the target has changed.

Every 15 ticks it walks the Warden's stalkers with forEachUnit and asks closestUnit for the nearest unit tagged delver within 9 tiles. It compares that answer against what the aggro hashtable already holds for that stalker. Same unit, and the trigger does nothing at all. Changed, and it stores the new target and issues one order.

{
  "k": "if",
  "cond": {
    "k": "ne",
    "a": { "k": "var", "name": "prey" },
    "b": { "k": "hashtableGet", "table": "aggro", "key1": { "k": "var", "name": "st" }, "key2": { "k": "int", "v": 0 } }
  },
  "then": [
    { "k": "hashtableSet", "table": "aggro", "key1": { "k": "var", "name": "st" }, "key2": { "k": "int", "v": 0 }, "value": { "k": "var", "name": "prey" } }
  ]
}

That guard is not an optimisation. An attack order re-issued every scan resets the swing, so a stalker re-ordered twice a second never lands a blow.

The post hashtable is the other half. t_init walks every stalker once and writes its own x and y into post under key2 0 and 1. When a stalker loses the scent, closestUnit answers nothing and the else branch orders it move back to the point it reads out of post. Six stalkers, six home tiles, and not one per-stalker variable: a hashtable keyed on the entity id is how you hold a value per unit when you do not know the units in advance. Reapers run the same loop with no maxRadius, so they never lose the scent.

How the match ends

Nothing in this map declares an outcome from a trigger body. Both endings go through one user-defined function, end_run(won, line), and the triggers only choose which argument to pass. end_run sets over, prints its line, then branches:

  • Won: forEachInt over the two escapee seats, issuing victory on each one that playerAlive says is still there, then defeat on the Warden seat.
  • Lost: the mirror. defeat on both escapee seats, then victory on the Warden.

Three call sites. t_escape passes true when a delver enters the vault region with seal_open set. t_clock passes false twice: once on seconds_left <= 0, once on delvers_alive <= 0. Both halves of an outcome matter, because an outcome only resolves for the seat the player is watching from. In a function the pairing is written once and cannot drift between the three conditions that reach it, and a fourth way to lose is one callFunction.

The measured numbers

The generator computes its design claims from its own geometry and fails the build if one stops holding. It measures the routes on the real navigation grid, not off the corridor lengths on paper, and converts them at 2.25 tiles per second, which was measured on the engine rather than read off the unit's speed of 3.2. A clock tuned from speed is about 40% too generous.

ClaimMeasured
Cheapest pair (Ford plus Warren, plus the trunk)52% of the clock
Safest pair (Warren plus Stair)88% of the clock
All three sigils105% of the clock, so it needs the +20s bonus
The two short routes cost the same time40 steps against 38
Threat curveCumulative hunters 1, then 2, then 4
Design claims asserted at build time6, none failing

The Ford's price is a fixed one, and no script charges it: the lava does, through the engine's own burn system. The generator prices the round trip at 171 HP of the delver's 420. The Warren's price is variable, because it depends on how the fight goes; the figure recorded for it in tools/examples/README.md is 254 HP, and lethal if the stalker's damage is raised from 20 to 24.

Run it yourself

An idle run proves the map is losable, which is the half a headless run can always check:

npx tsx packages/modkit/src/cli.ts play public/maps/escape-vault.json \
  --ticks 2400 --seed 4102 \
  --assert 'state=defeat' --assert 'var:sigils=0' --assert 'var:seconds_left<=0'
map      public/maps/escape-vault.json (seed 4102)
seats    0:dwarf/t0 (human, idle unless --drive) ✗  1:dwarf/t0 (human, idle unless --drive) ✗  2:undead/t1
ended    defeat — winner none at tick 1981 (99s) [trigger]
vars     warden=2, escapee=[0,1], seconds_left=0, sigils=0, delvers_alive=2, reaper_step=3, reapers=4, seal_open=false, over=true, i=1, warned=0, prey=0, st=6, one=[19], spawned=[18,19]
commands 0 issued by --drive
fires    Stalker chase AI ×132, The collapse clock ×100, Near-miss feedback ×99, Deaths — delvers and stalkers ×4, Map Initialization ×1
caps     0
broken   0
errors   0
asserts:
  PASS state=defeat  (actual: defeat)
  PASS var:sigils=0  (actual: 0)
  PASS var:seconds_left<=0  (actual: 0)
RESULT   OK

Three numbers there are worth reading. reapers=4 is the whole threat curve having run to its end. delvers_alive=2 says the clock ended this run, not the monsters, which is the other loss branch and a different thing to have proven. And caps 0 says the repeated closestUnit scans never came near a budget.

Winning it needs a player, or a driver that walks two delvers down two spurs. That is tools/examples/escapeVault.realEngine.test.ts, which is also where the tiles-per-second calibration is pinned, so an engine movement change fails loudly instead of quietly making the map trivial.

The map is also a lockstep gate. The desync harness runs one real client per seat, three of them here, and compares every client's state hash every turn. It is noisy, so filter it to the verdict:

npm run test:desync -- --map-file public/maps/escape-vault.json --turns 400 \
  2>/dev/null | grep -E '^(PASS|FAIL|  note)'
PASS — 400 turns, all clients hash-identical every turn (6.3s)
  note: world at load: 30 entities, 0 pre-placed army units
  note: relay: {"sent":3746,"delivered":3736,"dropped":0,"duplicated":0,"reordered":0}
  note: commands executed: 1263, entities at end: 31

The figure in brackets is wall clock and moves from run to run. Nothing else there should.

What to take from it

  • Re-order a unit only when its target changed. t_chase compares closestUnit against the aggro hashtable and skips the order when they match. Re-issuing an attack order every scan resets the swing.
  • Use a hashtable for per-unit state you cannot declare in advance. post holds each stalker's home tile under its entity id, written once by t_init with hashtableSet and read back by hashtableGet.
  • Give closestUnit a maxRadius when you want a unit to lose interest. Stalkers search 9 tiles and go home when the answer is nothing; reapers pass no radius and never stop.
  • Price a hazard with terrain, not with a script. The Ford charges HP because it is shallow lava, so there is no trigger to get the arithmetic wrong.
  • Route every outcome through one function. end_run holds the whole victory and defeat pairing, including the playerAlive guard.
  • Offset your polls. t_low_hp uses offsetTicks: 7 so it does not land on the same tick as the clock.

Changing it

This map is generated. Never hand-edit public/maps/escape-vault.json. Edit tools/examples/escape-vault.ts and re-run it:

npx tsx tools/examples/escape-vault.ts

The knobs are at the top of that file, and ESCAPE_SECONDS is the whole difficulty dial. Raising it does not silently make the map easy: the build asserts the three percentages above, so an untuned change fails with every measurement printed, including what the clock would have to be for an 88% safest pair. To check the committed JSON still matches without writing anything, run npx tsx tools/examples/build.ts --check, which also compares the map's entries in public/maps/featured.json and public/modding/templates.json.

Where to go next