Ironvale Legion is a Legion-TD-style lane race: ten rounds of creeps hit your lane and the rival's lane at the same time, and a leak only costs you if the rival held. You never give a combat order. The only decision is what each round's gold buys.
Play it
What it is made of
| Piece | Value |
|---|---|
| Size | 64, biome plains, mapType standard |
| Map format | "v": 2 |
| Seat 0 | user, dwarf, team 0, 130 starting gold |
| Seat 1 | computer, dwarf, team 1, 130 gold, passive (the rival) |
| Seat 2 | computer, ratmen, team 2, 0 gold, passive (the Legion) |
| Rules | standardVictory off; noStartingUnits and disableAi on; fog explored |
| Buildable | Five ids via allowedBuildings: legion_post_t1, legion_post_t2, legion_post_t3, legion_flak_post, legion_income_shrine |
| Placed | 12 buildings and 2 units: two Kings, two unowned Breach Stones, your 3 opening posts, the rival's 5, and two Marshals |
| Triggers | 8 |
| Variables | 18 |
| Regions | 6: spawn_w, spawn_e, king_w, king_e, build_w, build_e |
| Functions | 2: leak, end_game |
Three seats, three teams. The creeps belong to a third seat that neither player controls, so both lanes are attacked by the same neutral Legion and the two players never meet.
The rival starts ahead, with five posts to your three, and it can only repair what it has. You out-build it or you lose. That number was tuned: six posts for the rival and none for you was unwinnable, and three each was a photo finish where both Kings fell.
The King's region is a band across the lane, not the building. The creeps were once attack-moved at a tile inside the King building itself, which is an unreachable order, and they milled at the wall instead of leaking: 49 creeps standing in round 6 for 15 spawned, an eleven-tile lane permanently full, and a player who could not lay a foundation because every site was occupied. The region is now a band the creeps cross, and they are pointed at a clear tile behind it.
The lane is eleven tiles wide, and that is tower coverage. A post covers five to eight tiles, so in a 22-wide lane most creeps simply walk past every one: ten posts killed twelve creeps across six rounds. At eleven wide the same ten posts hold all ten rounds.
The decision
Each round pays a fixed income plus whatever bounty your wall earned. Three things to do with it:
- Spend. Cheap posts, early and often. More things on the board, each of them worse.
- Save. Nothing this round, then a tier-3 Bastion. Half as many things on the board, each of them much better.
- Buy income. An Income Shrine does nothing this round and pays every round after it.
The prices make that a real choice rather than a shopping list, and the generator asserts as much. A tier-3 Bastion costs 2.1 mid-game rounds' take. An Income Shrine breaks even on round 7 of 10, so it is a genuine bet on the back half rather than free value or a trap. Round 1 affords exactly one tier-1 post.
The shrine has a second effect that makes it more than an economy button: it sends a Raider into the rival's lane, so the rival part-funds your economy.
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 per lane, and raises the counters, the leaderboard, a createUIFrame King bar and the quests |
t_clock Round clock, income and the verdict | periodic, every 20 ticks, while not over | Counts next_round down, recounts on_field, refreshes the readouts, pays income and launches the round, then reaches the verdict. All three end_game calls live here |
t_round_clear Round cleared | periodic, every 20 ticks, while not over | When the Legion seat has no non-building units left and the round timer still has time on it, pulls the next round forward |
t_leak_w Leak, the Ironvale plinth | unitEntersRegion on king_w, filtered to ofPlayer the Legion seat | Branches on unitProp typeId of eventUnit to pick the damage, then callFunction into leak with mine set |
t_leak_e Leak, the rival plinth | unitEntersRegion on king_e, same filter | The same, with mine clear |
t_bounty Bounty for a kill | unitDies | Pays the killer and counts slain |
t_built A post, a flak spire or a shrine goes up | buildingComplete, filtered to the five buildable ids | Counts posts, or for a shrine raises income and sends a Raider |
t_rival_repairs The rival maintains its wall | periodic, every 560 ticks | Fully repairs every damaged rival post |
t_leak_w and t_leak_e are the same trigger twice, and the shared half is a
function. Each one reads the creep's type with unitProp typeId, picks the
damage for that type, and hands it to leak(king, dmg, mine, creep). The
function does the arithmetic, the bookkeeping and the cleanup:
{
"k": "setUnitHp",
"unit": { "k": "entityByAid", "aid": "king_mine" },
"value": {
"k": "sub",
"a": { "k": "unitProp", "unit": { "k": "entityByAid", "aid": "king_mine" }, "prop": "hp" },
"b": { "k": "var", "name": "dmg" }
},
"percent": false
}The last action of leak is removeUnit on the creep, and it matters more than
it looks. A creep that reaches a plinth and is not removed stands in the lane
forever. Forty-four ghost-occupied tiles once walled a wave out of its own goal
and made a showcase map unwinnable on every seed, so the real-engine suite
asserts every tile is released.
t_rival_repairs is a full repair, every round, with setUnitHp to
unitProp maxHp. The rival keeps its opening wall perfectly and never grows
it, which is what makes the comparison honest: you have the one thing it does
not, which is the ability to build more. A partial repair was tried at 20% a
round, and the rival's own King then died in an idle run too, which made the
outcome depend on which King happened to fall first.
t_built is where "buy income" becomes a build-menu choice. It branches on
unitProp typeId of eventUnit: an Income Shrine raises income by 22, and
then createUnits puts one legion_raider owned by the Legion seat at a
randomPointInRegion of the other lane's breach and orders it
attack_move at a clear tile behind that King. Anything else is a post and just
increments the counter. Nothing about this needs a special interface, which is
the point: a headless driver exercises it with the same build command a click
issues.
How the match ends
No trigger body in this map declares an outcome. Every ending goes through
one user-defined function, end_game(won, line), and t_clock is the only
caller.
end_game sets over, prints its line, then branches:
- Won:
victoryon seat 0,defeaton the rival,defeaton the Legion. - Lost:
defeaton seat 0,victoryon the rival,defeaton the Legion.
The Legion seat is defeated either way. It is never a winner, it is the map's antagonist, and forgetting it is how a match ends with a seat still sitting in it.
Three call sites, all in t_clock:
- The verdict. Nested under
next_round <= 0, after round 10, and only onceon_fieldis at or below 0 so the last round has resolved. It compares the two Kings withgeonunitProphpand callsend_gameeither way. - Your King is down:
unitProphpofentityByAidking_mineat or below 0. - The rival's King is down: the same test on
king_rival, and a win.
Three conditions, two outcomes, one place that pairs them. Adding a fourth way
to end the match is one callFunction.
The measured numbers
The generator computes eleven design claims from its own data and fails the
build if one stops holding. Running npx tsx tools/examples/build.ts --check
prints them:
| Series | Values across the ten rounds |
|---|---|
| Creeps per lane | 5, 7, 9, 11, 14, 15, 16, 18, 20, 22 |
| Wave HP | 700, 980, 1680, 2240, 2450, 4080, 5230, 6200, 7170, 11530 |
| Damage if the lane is ignored | 140, 196, 300, 388, 448, 642, 784, 916, 1048, 1424 |
| Bounty for a cleared round | 45, 63, 93, 119, 168, 192, 242, 284, 326, 465 |
Against a 3200 HP King, with 46 gold a round of base income and 22 more per shrine. Post prices: tier 1 is 80, tier 2 is 170, tier 3 is 440, a flak post is 155 and a shrine is 140. The ignored-lane series is asserted to kill an undefended King before the last round, while no single bad round ever ends it.
A sent Raider walks 81 steps to the rival's King against 48 for a creep that spawns in that lane, so a send arrives late and is not a free extra wave.
Then the real-engine suite measures the same claims in a played match. The
figures recorded in tools/examples/README.md:
- A win at round 10 of 10 with the King at 1412 of 3200 and the rival's King down.
- A loss by never spending a coin, with 1150 gold still banked. That is a loss by decision rather than by starvation, and it is worth saying plainly: the map did not starve the player, the player declined to play.
- Three build orders through the same seed end in three different places: King 2996, 2884 and 2856. Same map, same seed, same ticks, same build sites, in the same order. Only the spend changes.
- The income line's sends are the only thing that touches the rival's wall.
Minimum rival post health was 100% in the spend run and 38% in the income run.
That is a causal proof rather than a correlation: wave creeps only
move, so nothing else in either lane can have scratched it. - The total kill count is 186 across a held ten rounds.
The first attempt at that last claim compared leak counts between the two runs and came back 15 against 16. Two Raiders are inside the noise of a sixty-leak run, so that assertion could never have meant anything. Picking a quantity only your change can move is most of the work in a design gate.
Run it yourself
The loss is what a headless run reaches on its own, because nobody spends:
npx tsx packages/modkit/src/cli.ts play public/maps/legion-lite.json \
--ticks 7600 --seed 6102 \
--assert 'state=defeat' --assert 'var:posts=0' --assert 'var:income=46'map public/maps/legion-lite.json (seed 6102)
seats 0:dwarf/t0 (human, idle unless --drive) ✗ 1:dwarf/t1 2:ratmen/t2 ✗
ended defeat — winner none at tick 5421 (271s) [trigger]
vars me=0, rival=1, legion=2, round=10, rounds_max=10, next_round=27, income=46, shrines=0, posts=0, leaks=66, rival_leaks=43, sent=0, slain=119, on_field=46, over=true, i=0, u=12, spawned=[788]
commands 0 issued by --drive
fires Round clock, income and the verdict ×272, Round cleared ×252, Bounty for a kill ×120, Leak — the Ironvale plinth ×66, Leak — the rival plinth ×43, The rival maintains its wall ×10, Map Initialization ×1
caps 0
broken 0
errors 0
asserts:
PASS state=defeat (actual: defeat)
PASS var:posts=0 (actual: 0)
PASS var:income=46 (actual: 46)
RESULT OKFour numbers in that output are the map working as designed. posts=0 and
income=46 say nothing was ever bought. leaks=66 against rival_leaks=43
says the undefended lane leaked more than the rival's defended one, which is the
comparison the whole map is built on. on_field=46 is both lanes' round 10 on
the board at once, against the five-creep round 1 in the table above.
slain=119 is the interesting one: even an undefended lane kills things,
because the three pre-placed opening posts are still shooting. A control that
asserted zero kills would have been wrong, and the suite asserts the expected
winner did not win without a driver instead.
The fires line shows both leak triggers firing, which is what tells you the
creeps really crossed both plinth bands rather than piling up short of them.
Winning it needs a build order. tools/examples/legionLite.realEngine.test.ts
supplies three of them, sequentially, because two headless matches must never
run at once: the harness reaches shared singletons, and three concurrent matches
in one process cross-talk so badly that two of them finish with nothing built
and nothing killed while the third looks normal.
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/legion-lite.json --turns 400 \
2>/dev/null | grep -E '^(PASS|FAIL| note)'PASS — 400 turns, all clients hash-identical every turn (3.6s)
note: world at load: 33 entities, 0 pre-placed army units
note: relay: {"sent":3746,"delivered":3736,"dropped":0,"duplicated":0,"reordered":0}
note: commands executed: 1089, entities at end: 46What to take from it
- Write the shared half of a mirrored pair as a function.
t_leak_wandt_leak_ediffer only in which King they charge, so both callleakand the arithmetic exists once. removeUnitthe thing that reached the goal. A creep left standing on a plinth occupies its tile forever and can wall the next wave out of its own objective.- Charge HP by reading it back.
setUnitHpwithsubonunitProphpandpercent: falsetakes a flat amount, so the same leak costs the same whatever the King has left. - Make the economy a build-menu choice.
allowedBuildingsplus abuildingCompletebranch int_builtis the whole of "buy income", and it needs no new interface. - Defeat every seat that is not the winner, including the neutral one.
end_gamedefeats the Legion seat on both branches. - Pick a measurement only your change can move. Minimum rival post health is 100% without sends and 38% with. Leak counts, at 15 against 16, said nothing.
Changing it
This map is generated. Never hand-edit public/maps/legion-lite.json.
Edit tools/examples/legion-lite.ts and re-run it:
npx tsx tools/examples/legion-lite.tsThe knobs the generator names first are the tier-3 price against a round's take
(the whole saving decision, asserted to cost more than two mid-game rounds and
still be reachable by the midpoint), the shrine price against its income (the
break-even is asserted to land between round 4 and the second-to-last round),
ROUNDS, the two opening wall sizes, and the leak damage against the King's HP.
To check the committed JSON still matches the generator 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: Hero Siege is the same economy idea with combat orders in it.
- Walkthrough: Escape Maze is the same end-through-a-function pattern with no economy at all.
- Walkthrough: Castle Fight is the other buy-and-watch shape, where buildings send the army.
- Players & Teams covers the three-seat, three-team arrangement and why the neutral seat needs defeating too.
- Triggers and the Trigger Reference cover every node named above.