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
What it is made of
| Piece | What the file says |
|---|---|
| Map | 56 tiles square, gloom biome, mapType of standard |
| Seat 0 | user, dwarf, team 0. Starts on 200 gold and 120 lumber, and owns the Keep and the four builders |
| Seat 1 | computer, dwarf, team 0. The second co-op seat. Owns the two bolt towers and the two crossbowmen |
| Seat 2 | computer, undead, team 1. The horde. Owns nothing at tick 0 |
| Rules | disableAi: true, standardVictory: false, noStartingUnits: true, and allowedBuildings cut to three ids |
| Placed | 3 buildings (the Keep plus two bolt towers) and 6 units (four builders, two crossbowmen) |
| Script | 7 triggers, 12 variables, 6 regions |
| Data | 7 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
| Trigger | Fires on | What it does |
|---|---|---|
sv_init | mapInit, run once | Fences 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_close | dialogButton, run once | One hideDialog |
sv_dusk | timerExpires on the timer named dusk | Raises 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_pressure | periodic, every 200 ticks, while is_night | Picks one edge with rand, spawns a trickle there, and re-issues an attack order to every non-building unit of seat 2 |
sv_dawn | timerExpires on the timer named dawn | Clears 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_clock | periodic, every 20 ticks | Counts phase_left down, refreshes the leaderboard, recounts builders_alive, and runs the defeat check |
sv_death | unitDies, unfiltered | Branches 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 1map 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 OKNobody 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
startTimerandtimerExpiresfor 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.
forEachUnitoverunitsOfPlayerwithexcludeBuildings, plusremoveUnit, 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.
orderwithattack_moveat spawn time, then re-issued on aperiodic, is how adisableAi: truemap gets reliable pressure. - Read the objects that matter by name.
entityByAidonkeepandunitsOfTypeonsurv_buildersurvive anything the player builds or loses. A count of living buildings would not. - Fence the build area with
setBuildableRegion. Combined with a shortallowedBuildingslist, 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.tsThe 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
- Walkthrough: Deep Delve is the same genre with no building at all, and a much blunter victory rule.
- Walkthrough: Tanks of Stone takes the trigger-authored enemy behaviour further.
- The Genre Cookbook has the short recipe this map is the long version of.
- Triggers and the Trigger Reference cover every node named on this page.
- Players & Teams explains the rules block and why the seat an outcome names decides whether the match stops.
- Performance and limits is worth a read before you write a spawner of your own.