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
What it is made of
| Piece | Value |
|---|---|
| Size | 64, biome wasteland, mapType standard |
| Map format | "v": 2 |
| Seats 0 and 1 | user, dwarf, team 0, 150 starting gold each |
| Seat 2 | computer, goblin, team 1, passive |
| Rules | standardVictory off; noStartingUnits and disableAi on; fog explored |
| Buildable | siege_bolt_tower and siege_flak_tower only, via allowedBuildings |
| Placed | 4 buildings (gate, keep, forge, muster) and 6 units: 2 champions, 4 engineers |
| Triggers | 7 |
| Variables | 19 |
| Regions | 8: camp_n, camp_s, field, gatehouse, forge, build, rally, keep |
| Functions | 1: 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
| Trigger | When it fires | What it does |
|---|---|---|
t_init Map Initialization | mapInit, once | Marks 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 gate | periodic, every 20 ticks, while not over | Counts 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 rolling | periodic, every 100 ticks, while not over | Recounts rams_alive and re-issues order attack at entityByAid gate for every living ram |
t_bounty Bounty, kills and deaths | unitDies | Pays the killer, counts slain, decrements rams_alive, and handles a dead champion |
t_wave_clear Wave cleared | periodic, every 20 ticks, offsetTicks: 11 | When 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 Forge | unitEntersRegion on forge, filtered to unitTypeIds: ["siege_champion"] | The repair. Described below |
t_tower A tower goes up | buildingComplete, filtered to the two tower ids | Counts 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:
forEachIntover the two champion seats, issuingvictoryon each oneplayerAlivestill reports, thendefeaton the besieger seat. - Fallen: the mirror, with
victoryon the besieger.
Two call sites, both inside t_clock:
- The win, nested under
next_wave <= 0after the last wave:besiegers_aliveat or below 0 andunitProphpofentityByAidgateabove 0. - The loss:
unitPropaliveof 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
unitPropownerofeventUnit, so the champion who made the trip pays, not seat 0. setUnitHpadds to the gate's currenthprather 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:
| Series | Values across the five waves |
|---|---|
| Swarm size | 4, 7, 9, 12, 14 |
| Swarm damage per second | 52, 94, 121, 159, 196 |
| Ram damage per second | 67, 78, 145, 156, 83 |
| Seconds for the rams alone to break the gate | 120, 103, 55, 51, 97 |
| Bounty for a fully cleared wave | 126, 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:
| Wave | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Besiegers on the field at the peak | 5 | 8 | 11 | 22 | 21 |
| Rams on the field at the peak | 1 | 1 | 2 | 2 | 3 |
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 OKThat 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: 35What to take from it
- Pick the order kind for the job.
orderwith"order": "attack"and an explicittargetis the only one that sends a unit across a map at one objective.attack_movestops to fight;movearrives and does nothing. - Pay the killer, and guard the lookup.
eventOtherUnitis the killer andeventPlayeris the corpse's owner. CheckeventOtherUnitis not 0 before readingunitPropownerfrom it, because a missing unit answers seat 0. - Filter on
unitTypeIdsfor anything that respawns. A unit fromcreateUnitshas no tag and noaid, so a tag filter stops matching after the first death and says nothing. - Spend with a negative
addResource.t_forgecharges 120 gold that way and raises the gate withsetUnitHpin the same tick, so the trade is atomic. - Say why you refused. The else branch of
t_forgeexplains itself, becauseunitEntersRegiononly 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.tsThe 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 --checkThat compares the map bytes and its entries in public/maps/featured.json and
public/modding/templates.json.
Where to go next
- Walkthrough: Legion Lite takes the same economy idea and makes it the whole map.
- Walkthrough: Escape Maze uses the same end-through-a-function pattern with no economy at all.
- Data Overrides explains the cloned ids this map fights with.
- Triggers and the Trigger Reference cover every node named above.
- Performance and limits explains the
budgets the
capsline reports against.