Map Editor Documentation

Walkthrough: Hero Siege

A read-through of Siege of Ember Gate: five waves that each send a swarm and a ram at opposite targets, bounty paid to the killer, and a repair that buys back the past and nothing of the future.

Genres · Page 43 of 49

Siege of Ember Gate is a hero siege for one or two champions: five waves, each with a swarm that carries the bounty and a ram that carries the gate's death. They want opposite things from you, and you cannot answer both.

Play it

Siege of Ember Gate

What it is made of

PieceValue
Size64, biome wasteland, mapType standard
Map format"v": 2
Seats 0 and 1user, dwarf, team 0, 150 starting gold each
Seat 2computer, goblin, team 1, passive
RulesstandardVictory off; noStartingUnits and disableAi on; fog explored
Buildablesiege_bolt_tower and siege_flak_tower only, via allowedBuildings
Placed4 buildings (gate, keep, forge, muster) and 6 units: 2 champions, 4 engineers
Triggers7
Variables19
Regions8: camp_n, camp_s, field, gatehouse, forge, build, rally, keep
Functions1: end_siege

The Ember Gate is owned by the defenders' seat rather than left neutral, because a gate-role building lets its owner's allies route through and refuses everyone else. The besiegers therefore have to break it.

Two of the four engineers are not decoration. A champion cannot build: only a builder unit can, and a build command issued from a non-builder is dropped with no error anywhere. Two engineers per seat also means a tower plan can be round-robined, because one builder laying several foundations queues them and only the first ever finishes.

The decision

Every wave sends both a swarm and a ram, and the ram is armoured against towers and out-ranges them, so a champion has to deal with it in person. That gives you three ways to spend a wave:

  • Hunt the ram. The gate barely bleeds, you earn almost nothing, and you spend the wave out of position taking swarm damage.
  • Farm the swarm. Bounty for towers, and the gate pays for it.
  • Run to the Forge. 120 gold and the walk, for 400 gate HP.

Towers compound, because they help every later wave. Repairs do not, because they only undo. That asymmetry is the whole economy.

The design got here by being wrong twice, and the generator's header comment says so. With the muster stone out in the open field, two idle champions stood on the gate's doorstep and auto-retaliated everything to death: the measured gate HP came out higher after a do-nothing run than after a run that chased rams, so the best strategy was to stand still and the map had no decision in it. The muster moved inside the keep. A champion that does nothing is now safe and useless, the loss is reachable, and going out to fight is the game.

How it works

TriggerWhen it firesWhat it does
t_init Map InitializationmapInit, onceMarks the seats, sets the time of day, calls setBuildableRegion, and raises the counters, leaderboard rows, a createUIFrame gate bar and the quests
t_clock Wave clock and the watch on the gateperiodic, every 20 ticks, while not overCounts next_wave down, recounts besiegers_alive, refreshes the readouts, respawns dead champions, launches the next wave or calls the verdict, and calls end_siege when the gate is gone
t_ram_focus Rams keep rollingperiodic, every 100 ticks, while not overRecounts rams_alive and re-issues order attack at entityByAid gate for every living ram
t_bounty Bounty, kills and deathsunitDiesPays the killer, counts slain, decrements rams_alive, and handles a dead champion
t_wave_clear Wave clearedperiodic, every 20 ticks, offsetTicks: 11When the besieger seat has no non-building units left and the wave timer has time on it, pulls the next wave forward
t_forge The Forward ForgeunitEntersRegion on forge, filtered to unitTypeIds: ["siege_champion"]The repair. Described below
t_tower A tower goes upbuildingComplete, filtered to the two tower idsCounts towers and says so

The ram order is the one that decides whether this map works. All three order kinds were tried on it. attack_move made the ram duel the nearest champion and never arrive, so the gate was still at full health entering wave 4. move made it arrive and then stand there, so the gate took no damage all match and the map could not be lost. Only order with "order": "attack" and the gate as target sends a unit across the whole map at one objective.

Bounty is paid to the killer, and the event carries three different units. On unitDies, eventUnit is the corpse, eventPlayer is the corpse's owner, and eventOtherUnit is the killer. t_bounty reads the dead unit's type from eventString and pays gold to unitProp owner of eventOtherUnit.

{
  "k": "if",
  "cond": { "k": "ne", "a": { "k": "eventOtherUnit" }, "b": { "k": "int", "v": 0 } },
  "then": [
    {
      "k": "if",
      "cond": { "k": "eq", "a": { "k": "eventString" }, "b": { "k": "str", "v": "siege_ram" } },
      "then": [
        {
          "k": "addResource",
          "player": { "k": "unitProp", "unit": { "k": "eventOtherUnit" }, "prop": "owner" },
          "resource": "gold",
          "delta": { "k": "int", "v": 70 }
        }
      ]
    }
  ]
}

The ne eventOtherUnit 0 guard is load-bearing. unitProp of a missing unit answers 0, and 0 is a real seat, so a creep that dies to something with no killer would otherwise pay seat 0 for a kill it had nothing to do with.

Every trigger that has to recognise a champion filters on unitTypeIds, never on tags. A unit made by createUnits carries no authored component, so a respawned champion has no aid and no tag. A tag filter would work for the whole first life and then silently stop matching, which is the worst kind of bug to find.

How the match ends

No trigger body in this map declares an outcome. Both endings go through one user-defined function, end_siege(held, line), and t_clock is the only caller.

end_siege sets over, prints its line, then branches:

  • Held: forEachInt over the two champion seats, issuing victory on each one playerAlive still reports, then defeat on the besieger seat.
  • Fallen: the mirror, with victory on the besieger.

Two call sites, both inside t_clock:

  • The win, nested under next_wave <= 0 after the last wave: besiegers_alive at or below 0 and unitProp hp of entityByAid gate above 0.
  • The loss: unitProp alive of the gate is false. Checked once a second on the clock rather than on a death event, so there is one place that can end the match and one place to read when it did not.

The repair economy

t_forge is the clearest small piece of economy wiring on any of these maps. It fires on unitEntersRegion filtered to champions, and asks two questions with playerResource and unitProp hpPercent: does the seat that walked in have 120 gold, and is the gate worth mending? If both hold, it spends the gold with a negative addResource and raises the gate with setUnitHp.

Three details make it correct rather than nearly correct:

  • The seat charged is unitProp owner of eventUnit, so the champion who made the trip pays, not seat 0.
  • setUnitHp adds to the gate's current hp rather than setting a percentage, so a repair is always the same 400 HP.
  • The else branch says out loud why it refused. An entry event only fires on entry, so a trip that arrives at a bad moment and gets a silent refusal never asks again.

The measured numbers

The generator asserts eleven design claims from its own wave table and fails the build if one stops holding. Running npx tsx tools/examples/build.ts --check prints them:

SeriesValues across the five waves
Swarm size4, 7, 9, 12, 14
Swarm damage per second52, 94, 121, 159, 196
Ram damage per second67, 78, 145, 156, 83
Seconds for the rams alone to break the gate120, 103, 55, 51, 97
Bounty for a fully cleared wave126, 216, 318, 424, 722 gold

The gate has 8000 HP, a tower costs 120 gold, a flak spire 150, and a repair 120 for 400 HP. If every ram is ignored, the table says the gate finishes 7825 HP in the red.

Then the real-engine suite measures the same claims in a played match, because a table that rises and a match that gets harder are different facts. The figures recorded in tools/examples/README.md are a win at 211 seconds with the gate at 67%, a loss by doing nothing at 97 seconds, and this curve observed through the real spawner rather than read off the table:

Wave12345
Besiegers on the field at the peak58112221
Rams on the field at the peak11223

Read the two together. The intended swarm rises at every step, 12 then 14. The observed peak dips, 22 then 21, because what is standing on the field includes the previous wave's survivors. A table of intended numbers would have hidden that entirely, and the test's curve assertion is written to tolerate a dip of one for exactly this reason.

The dilemma has its own measurement. The same map, seed, tower plan and tick budget are played twice, changing only whether the champions prefer rams. The gap in surviving gate HP was observed between 459 and 918 across seeds and tick budgets, and the test's floor is 5% of the gate. If that gap shrinks, the ram has stopped being a decision.

Run it yourself

The loss is the half a headless run can check on its own, because nobody is at the keyboard:

npx tsx packages/modkit/src/cli.ts play public/maps/hero-siege.json \
  --ticks 2400 --seed 5102 \
  --assert 'state=defeat' --assert 'var:towers=0' --assert 'var:wave<=5'
map      public/maps/hero-siege.json (seed 5102)
seats    0:dwarf/t0 (human, idle unless --drive) ✗  1:dwarf/t0 (human, idle unless --drive) ✗  2:goblin/t1
ended    defeat — winner none at tick 1941 (97s) [trigger]
vars     besieger=2, champ_seat=[0,1], wave=3, wave_max=5, next_wave=7, besiegers_alive=24, rams_alive=4, slain=0, repairs=0, towers=0, respawn_in=[0,0], boss_out=false, gate_warned=true, over=true, i=1, u=0, prey=0, one=[], spawned=[72]
commands 0 issued by --drive
fires    Wave clock and the watch on the gate ×98, Wave cleared ×83, Rams keep rolling ×20, Map Initialization ×1, Bounty, kills and deaths ×1
caps     0
broken   0
errors   0
asserts:
  PASS state=defeat  (actual: defeat)
  PASS var:towers=0  (actual: 0)
  PASS var:wave<=5  (actual: 3)
RESULT   OK

That reproduces the recorded loss exactly: the gate falls at 97 seconds, during wave 3, with four rams still rolling. towers=0 and repairs=0 are what a map with nobody in it looks like, and they are the control for the win run: the suite plays the same map with a driver that lays towers and hunts rams, and asserts those same seats do not win when the driver is removed.

To win it you need a player or a driver, which is what tools/examples/heroSiege.realEngine.test.ts provides.

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:

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

What to take from it

  • Pick the order kind for the job. order with "order": "attack" and an explicit target is the only one that sends a unit across a map at one objective. attack_move stops to fight; move arrives and does nothing.
  • Pay the killer, and guard the lookup. eventOtherUnit is the killer and eventPlayer is the corpse's owner. Check eventOtherUnit is not 0 before reading unitProp owner from it, because a missing unit answers seat 0.
  • Filter on unitTypeIds for anything that respawns. A unit from createUnits has no tag and no aid, so a tag filter stops matching after the first death and says nothing.
  • Spend with a negative addResource. t_forge charges 120 gold that way and raises the gate with setUnitHp in the same tick, so the trade is atomic.
  • Say why you refused. The else branch of t_forge explains itself, because unitEntersRegion only fires on entry and a silent refusal is never retried.
  • Measure the curve through the spawner, not off the table. The intended counts rise at every wave; the observed peak does not, and only one of those is what the player feels.

Changing it

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

npx tsx tools/examples/hero-siege.ts

The wave table is data at the top of the file, and the generator asserts that count, total damage per second and ram tonnage all rise strictly. Add a row and the assertions tell you whether you actually made it harder. The other knobs worth turning first are REPAIR_COST against REPAIR_HP, the tower cost against the bounty table, and WAVE_TICKS, which is 30 seconds: shorter makes the ram decision frantic, longer makes it a shopping trip.

To check the committed JSON still matches without writing anything:

npx tsx tools/examples/build.ts --check

That compares the map bytes and its entries in public/maps/featured.json and public/modding/templates.json.

Where to go next