Map Editor Documentation

Walkthrough: Survival

A read-through of the shipped survival showcase: a day and night cycle run on two named timers, a horde spawned at dusk and deleted at dawn, and a match decided on the sixth sunrise.

Genres · Page 39 of 49

Nightfall Hold is the shipped survival map: four builders, one Keep, and six nights of undead. Every day it asks the same question, which is whether the four forest corridors are walled before the sun goes down.

Play it

Nightfall Hold

What it is made of

PieceWhat the file says
Map56 tiles square, gloom biome, mapType of standard
Seat 0user, dwarf, team 0. Starts on 200 gold and 120 lumber, and owns the Keep and the four builders
Seat 1computer, dwarf, team 0. The second co-op seat. Owns the two bolt towers and the two crossbowmen
Seat 2computer, undead, team 1. The horde. Owns nothing at tick 0
RulesdisableAi: true, standardVictory: false, noStartingUnits: true, and allowedBuildings cut to three ids
Placed3 buildings (the Keep plus two bolt towers) and 6 units (four builders, two crossbowmen)
Script7 triggers, 12 variables, 6 regions
Data7 unit overrides and 3 building overrides, all patched off stock defs

The three ids in allowedBuildings are the whole build menu. That one rules field is what makes this a fortify map rather than a skirmish: there is no town hall to build, no tech tree to climb, and nothing to spend lumber on except walls and towers.

The Keep carries the author id keep, and the four builders are the only units of type surv_builder on the map. Both facts are load bearing. The defeat check reads the Keep by that author id, and counts surv_builder by type across seats 0 and 1, so neither half of it cares what the players have built or lost since.

How it works

TriggerFires onWhat it does
sv_initmapInit, run onceFences each seat's build area with setBuildableRegion, sets setTimeOfDay to 0, which is dawn, starts the dusk timer at 1800 ticks, writes the leaderboard and quests, and shows the briefing dialog
sv_briefing_closedialogButton, run onceOne hideDialog
sv_dusktimerExpires on the timer named duskRaises night, sets is_night, moves setTimeOfDay to 0.5, starts the dawn timer at 1000 ticks, and spawns that night's horde at all four edge regions
sv_night_pressureperiodic, every 200 ticks, while is_nightPicks one edge with rand, spawns a trickle there, and re-issues an attack order to every non-building unit of seat 2
sv_dawntimerExpires on the timer named dawnClears is_night, returns the clock to dawn, deletes every surviving horde unit, and then either ends the match or starts the next dusk timer at 1200 ticks and pays both dwarf seats a stipend
sv_clockperiodic, every 20 ticksCounts phase_left down, refreshes the leaderboard, recounts builders_alive, and runs the defeat check
sv_deathunitDies, unfilteredBranches on eventPlayer and eventString: pays a 3 gold bounty for a horde death, and announces a lost builder

The cycle is two timers, not a tick counter. sv_init starts a timer called dusk. When it expires, sv_dusk starts a timer called dawn. When that expires, sv_dawn starts dusk again, and the map loops for as long as it needs to. Nothing anywhere counts ticks toward the next phase. The trade is that the phase lengths live in the startTimer actions rather than in one constant, and in exchange a phase can be any length you like: day 1 is 1800 ticks, later days are 1200, and nights are 1000. At 20 ticks per second that is 90 seconds, 60 seconds and 50 seconds.

The handoff is the whole trick, and it is the top of the dusk trigger:

{
  "id": "sv_dusk",
  "name": "Dusk: The Horde Comes",
  "events": [{ "k": "timerExpires", "timer": "dusk" }],
  "conditions": [{ "k": "not", "a": { "k": "var", "name": "game_over" } }],
  "actions": [
    { "k": "addVar", "name": "night", "delta": { "k": "int", "v": 1 } },
    { "k": "setVar", "name": "is_night", "value": { "k": "bool", "v": true } },
    { "k": "startTimer", "timer": "dawn", "ticks": { "k": "int", "v": 1000 } }
  ]
}

phase_left is a second, separate thing: it is the number on the leaderboard, decremented once a second by sv_clock. It is display only. If it drifted from the real timer the phase would still change on time, because the timer is what the event listens to.

Dawn deletes the horde, and that is what keeps the map playable. sv_dawn runs one action over the whole enemy seat:

{
  "k": "forEachUnit",
  "group": {
    "k": "unitsOfPlayer",
    "player": { "k": "int", "v": 2 },
    "filter": { "excludeBuildings": true }
  },
  "varName": "u_burn",
  "body": [{ "k": "removeUnit", "unit": { "k": "var", "name": "u_burn" } }]
}

Without it, a night the player nearly lost would leave forty stragglers wandering the map, and night 2 would start with night 1 still in progress. Six nights of that compound into a map that cannot be won, and that runs slowly while it cannot be won. The filter matters too: excludeBuildings is what stops the sweep from deleting anything the horde seat might own that is not part of the wave.

disableAi: true means the horde has no brain, so the map gives it one. Every spawn is followed immediately by an order with attack_move aimed at regionCenter of the keep region, and sv_night_pressure re-issues that order to the whole seat every 200 ticks, so anything that stopped short is sent on again. Trigger-authored orders like this are the normal shape for a survival map: predictable pressure on a known point beats an RTS AI that might decide to expand.

How the match ends

The win is in sv_dawn. After it has burned the horde away it tests night >= nights_total, and when that holds it sets game_over, then issues victory for seat 0, victory for seat 1 and defeat for seat 2. nights_total starts at 6, so the sixth sunrise wins.

The loss is in sv_clock, the same one-second trigger that draws the leaderboard. Its condition is builders_alive == 0 or the keep entity is not alive, read through unitProp on entityByAid. When either is true it sets game_over and issues defeat for seat 0, defeat for seat 1 and victory for seat 2.

Both outcomes name all three seats. That is deliberate, and it is the rule from Your First Win Condition: an outcome resolves for the seat the player is watching from, so the losing human seat has to be told it lost. game_over then gates the conditions on sv_dusk, sv_night_pressure, sv_dawn and sv_clock, so nothing keeps spawning after the match is decided.

Run it yourself

npx tsx packages/modkit/src/cli.ts play public/maps/survival-showcase.json \
  --ticks 2400 --seed 1
map      public/maps/survival-showcase.json (seed 1)
seats    0:dwarf/t0 (human, idle unless --drive)  1:dwarf/t0  2:undead/t1
ended    no — still running after 2400 ticks (120s)
vars     night=1, nights_total=6, phase_left=20, is_night=true, game_over=false, horde_slain=16, builders_alive=3, edge=3, e=0, killer=1, spawned=[110], u_burn=0
commands 0 issued by --drive
fires    One-second Clock and Defeat Check ×119, Deaths: Bounty and Builder Loss ×19, Night Reinforcements ×3, Map Initialization ×1, Dusk: The Horde Comes ×1
caps     0
broken   0
errors   0
RESULT   OK

Nobody built anything, because a headless run has no player in it, and the map is still standing at 120 seconds with one builder already lost. Read that as four separate confirmations: the dusk timer fired once on schedule, sv_night_pressure has run three times since night fell, 16 horde units have died to the garrison, and builders_alive is being recounted rather than assumed.

The map also has real outcome tests, which drive a seat and check both endings: src/game/triggers/SurvivalShowcase.test.ts, plus a win case and a loss case in tools/ai_loop/showcaseOutcomes.realEngine.test.ts.

What to take from it

  • Use startTimer and timerExpires for phases. Two timers that restart each other give you a loop with no counter and no per-tick work, and each phase can have its own length.
  • Delete what you spawn. forEachUnit over unitsOfPlayer with excludeBuildings, plus removeUnit, is the whole clean-up. A wave map without one gets slower and harder every wave for a reason nobody can see.
  • Give trigger-spawned enemies a standing order. order with attack_move at spawn time, then re-issued on a periodic, is how a disableAi: true map gets reliable pressure.
  • Read the objects that matter by name. entityByAid on keep and unitsOfType on surv_builder survive anything the player builds or loses. A count of living buildings would not.
  • Fence the build area with setBuildableRegion. Combined with a short allowedBuildings list, it is the difference between a fortify map and a skirmish with a stopwatch.

Changing it

This map is generated. Do not edit public/maps/survival-showcase.json: the next regeneration will overwrite it. The map is defined in tools/generate_showcase_maps.ts, where the trigger list is written through small TypeScript helpers that emit the real createUnits, order and forEachUnit nodes, so the shapes you read there are the shapes the validator sees. Edit that file, then run:

npx tsx tools/generate_showcase_maps.ts

The same generator also builds public/maps/survivor-horde.json, a 60 tile, two seat horde map with 7 triggers of its own. It is not listed in public/maps/featured.json, so it is not on the Custom Games shelf and there is no link to it here. Read the file anyway: it is the only shipped map that uses the heroLevels event. When the hero gains a level, that trigger reads the new level from eventValue, announces it, and opens a three-button showDialog. A second trigger on dialogButton then compares eventValue against 0, 1 and 2 to work out which boon was picked. That pair is the whole pattern for a level-up choice, and no other shipped map demonstrates it.

Where to go next