Map Editor Documentation

Walkthrough: Lane Race

A read-through of Ironvale Legion: ten rounds against both lanes at once, where the only decision is what each round's gold buys, and income that pays back on round 7 of 10.

Genres · Page 44 of 49

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

Ironvale Legion

What it is made of

PieceValue
Size64, biome plains, mapType standard
Map format"v": 2
Seat 0user, dwarf, team 0, 130 starting gold
Seat 1computer, dwarf, team 1, 130 gold, passive (the rival)
Seat 2computer, ratmen, team 2, 0 gold, passive (the Legion)
RulesstandardVictory off; noStartingUnits and disableAi on; fog explored
BuildableFive ids via allowedBuildings: legion_post_t1, legion_post_t2, legion_post_t3, legion_flak_post, legion_income_shrine
Placed12 buildings and 2 units: two Kings, two unowned Breach Stones, your 3 opening posts, the rival's 5, and two Marshals
Triggers8
Variables18
Regions6: spawn_w, spawn_e, king_w, king_e, build_w, build_e
Functions2: 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

TriggerWhen it firesWhat it does
t_init Map InitializationmapInit, onceMarks 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 verdictperiodic, every 20 ticks, while not overCounts 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 clearedperiodic, every 20 ticks, while not overWhen 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 plinthunitEntersRegion on king_w, filtered to ofPlayer the Legion seatBranches on unitProp typeId of eventUnit to pick the damage, then callFunction into leak with mine set
t_leak_e Leak, the rival plinthunitEntersRegion on king_e, same filterThe same, with mine clear
t_bounty Bounty for a killunitDiesPays the killer and counts slain
t_built A post, a flak spire or a shrine goes upbuildingComplete, filtered to the five buildable idsCounts posts, or for a shrine raises income and sends a Raider
t_rival_repairs The rival maintains its wallperiodic, every 560 ticksFully 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: victory on seat 0, defeat on the rival, defeat on the Legion.
  • Lost: defeat on seat 0, victory on the rival, defeat on 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 once on_field is at or below 0 so the last round has resolved. It compares the two Kings with ge on unitProp hp and calls end_game either way.
  • Your King is down: unitProp hp of entityByAid king_mine at 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:

SeriesValues across the ten rounds
Creeps per lane5, 7, 9, 11, 14, 15, 16, 18, 20, 22
Wave HP700, 980, 1680, 2240, 2450, 4080, 5230, 6200, 7170, 11530
Damage if the lane is ignored140, 196, 300, 388, 448, 642, 784, 916, 1048, 1424
Bounty for a cleared round45, 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   OK

Four 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: 46

What to take from it

  • Write the shared half of a mirrored pair as a function. t_leak_w and t_leak_e differ only in which King they charge, so both call leak and the arithmetic exists once.
  • removeUnit the 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. setUnitHp with sub on unitProp hp and percent: false takes a flat amount, so the same leak costs the same whatever the King has left.
  • Make the economy a build-menu choice. allowedBuildings plus a buildingComplete branch in t_built is the whole of "buy income", and it needs no new interface.
  • Defeat every seat that is not the winner, including the neutral one. end_game defeats 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.ts

The 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 --check

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

Where to go next