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
What it is made of
| Piece | Value |
|---|---|
| Size | 64, biome cave, mapType standard |
| Map format | "v": 3 |
| Seats 0 and 1 | user, dwarf, team 0, 0 starting gold |
| Seat 2 | computer, undead, team 1, passive |
| Rules | standardVictory off; noStartingUnits and disableAi on; allowedBuildings empty; fog normal |
| Placed | 4 unowned buildings (the seal and three shrines) and 8 units: 2 delvers, 6 stalkers |
| Triggers, variables | 12 and 15 |
| Regions | 8: hall, ford, warren, stair, sigil_a, sigil_b, sigil_c, vault |
| Functions | 2: take_sigil, end_run |
| Hashtables | 2: 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:
| Route | Round trip, in walkable steps | What it costs |
|---|---|---|
| Ember Ford | 40 | Hit points. 12 tiles of shallow lava, burning at 16 HP a second |
| Stalker Warren | 38 | Risk. A 6-strong pack with trigger-authored chase AI |
| Long Stair | 120 | Time, 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
| Trigger | When it fires | What it does |
|---|---|---|
t_init Map Initialization | mapInit, once | Sets 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 clock | periodic, every 20 ticks, while not over | Counts 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_c | unitEntersRegion on that sigil region, filtered to tags: ["delver"], run once | removeUnit on the shrine, a ping, then callFunction into take_sigil |
t_escape Escape, the win | unitEntersRegion on vault, filtered to tags: ["delver"], run once | Guarded on seal_open. A fadeScreen, then callFunction into end_run with true |
t_ford / t_warren / t_stair | unitEntersRegion on that route, run once | One line of warning the first time a delver commits to a route |
t_chase Stalker chase AI | periodic, every 15 ticks, while not over | The pack. Described below |
t_deaths Deaths | unitDies | Branches on eventString to count a dead delver or a dead hunter |
t_low_hp Near-miss feedback | periodic, every 20 ticks, offsetTicks: 7 | Walks 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:
forEachIntover the two escapee seats, issuingvictoryon each one thatplayerAlivesays is still there, thendefeaton the Warden seat. - Lost: the mirror.
defeaton both escapee seats, thenvictoryon 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.
| Claim | Measured |
|---|---|
| Cheapest pair (Ford plus Warren, plus the trunk) | 52% of the clock |
| Safest pair (Warren plus Stair) | 88% of the clock |
| All three sigils | 105% of the clock, so it needs the +20s bonus |
| The two short routes cost the same time | 40 steps against 38 |
| Threat curve | Cumulative hunters 1, then 2, then 4 |
| Design claims asserted at build time | 6, 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 OKThree 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: 31The 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_chasecomparesclosestUnitagainst theaggrohashtable and skips theorderwhen they match. Re-issuing an attack order every scan resets the swing. - Use a hashtable for per-unit state you cannot declare in advance.
postholds each stalker's home tile under its entity id, written once byt_initwithhashtableSetand read back byhashtableGet. - Give
closestUnitamaxRadiuswhen 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_runholds the wholevictoryanddefeatpairing, including theplayerAliveguard. - Offset your polls.
t_low_hpusesoffsetTicks: 7so 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.tsThe 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
- Walkthrough: Hero Siege is the same end-through-a-function pattern with an economy attached.
- Walkthrough: Lane Race makes the economy the entire decision.
- Lava and Biomes covers the terrain that charges the Ember Ford.
- Triggers and the Trigger Reference cover every node named above, including the hashtable family.
- Your First Win Condition explains the
seat rule that
end_runis built around.