Map Editor Documentation

Walkthrough: Tanks of Stone

A read-through of the hand-authored tank arena: five rounds counted by one death trigger, enemy movement written entirely in triggers, and a barricade field you shoot holes in.

Genres · Page 41 of 49

Tanks of Stone is one tank against a yard full of other tanks, across five rounds of an arena walled with wooden barricades. The question it asks over and over is whether you can put something solid between you and the artillery before the next shell lands.

Play it

Tanks of Stone: Steam & Steel

What it is made of

PieceWhat the file says
Map48 tiles square, wasteland biome, mapType of arena
Capabilitiesmeta.capabilities records legacy render targets; The Forge and custom matches now use 3D
Seat 0user, dwarf, team 0. Starts on 100 gold and owns the player tank
Seat 1computer, dwarf, team 1. Every hostile tank belongs to it
Seat 2computer, dwarf, team 2. Owns all 26 barricades and nothing else
RulesdisableAi: true, standardVictory: false, noStartingUnits: true, allowedBuildings empty
Placed27 buildings (seat 0's town hall, seat 2's 26 tank_barricade) and 4 units (seat 0's tank, seat 1's three scouts)
Script6 triggers, 6 variables, 7 regions
Data5 unit overrides and 1 building override

All five tank types are patched off the same base def, dwarf_steam_tank, and differ only in the numbers: the scout has 140 HP and 6 range, the artillery has 240 HP and 9.5 range, and the boss has 2600 HP and 8.5 range. That is the cheapest way to get an arcade roster, because all five inherit art the game already ships. The barricade is dwarf_wall patched down to 180 HP, so the map's whole terrain puzzle is a shootable building rather than anything to do with terrain.

The third seat is the reason the barricade field works. Seat 2 is on its own team, so its 26 barricades are hostile to seat 0 and to seat 1 alike, and either side can shoot a hole through them. Putting them on a seat of its own also keeps them out of every seat-scoped query the map makes: unitsOfPlayer for seat 1 never picks them up, and the round counter, which only counts deaths belonging to seat 1, never mistakes a collapsed wall for a kill.

The player tank carries the author id player_tank. Five of the six triggers read it through entityByAid, every one of them except the briefing closer, so that one string is the map's single handle on the thing the whole match is about.

How it works

TriggerFires onWhat it does
t_tank_initmapInit, run onceSets the leaderboard title and five rows, sets the quest, shows the briefing, and sends seat 1's three scouts at player_base with one order
t_tank_briefing_closedialogButton for tank_briefing, run onceOne hideDialog
t_tank_clockperiodic, every 20 ticks, while not game_overAdds 1 to time_elapsed, redraws all five leaderboard rows, and runs the defeat check
t_tank_ai_huntperiodic, every 80 ticks, while not game_overRe-orders every non-building unit of seat 1 to attack-move at the player tank's current position
t_tank_airdropperiodic, every 1000 ticks, while not game_overAnnounces a drop, pays seat 0 100 gold, and sets the player tank back to 600 HP
t_tank_killunitDies, unfilteredEverything else: bounty, the round counter, the next round's spawns, and the win

The enemy behaviour is a trigger, not the AI. disableAi: true switches the normal RTS AI off for every seat, so t_tank_ai_hunt supplies the whole of it in one action, every 80 ticks:

{
  "k": "order",
  "units": {
    "k": "unitsOfPlayer",
    "player": { "k": "int", "v": 1 },
    "filter": { "excludeBuildings": true }
  },
  "order": "attack_move",
  "to": {
    "k": "unitPoint",
    "unit": { "k": "entityByAid", "aid": "player_tank" }
  }
}

unitPoint reads the target's position at the moment the order is issued, so re-issuing it is what turns a one-off move order into a hunt. excludeBuildings keeps the map's buildings out of the group. The result is predictable in a way an RTS AI is not: the tanks always come at you, they never stop to expand, and a player can learn the pattern. That predictability is the point of the genre, and it is what disableAi: true buys.

Note the rate. Every 80 ticks is once every four seconds, which is often enough that the tanks track a moving target and rare enough that the cost never matters. The cost is one group scan, charged per unit walked, against the expression budget described in Performance and limits. At a handful of tanks that is nothing. The same trigger over two hundred units would not be.

Rounds are counted by one unfiltered death trigger. t_tank_kill listens to every unitDies on the map and gates its whole body behind eventPlayer == 1, so a seat 2 barricade falling or the player's own tank dying does nothing there. When a hostile tank dies it adds to tanks_destroyed, takes one off tanks_alive, and pays 50 gold. Then it asks whether tanks_alive == 0 and round < 5. If so it raises round, repairs the player tank to 600 HP, and runs the branch for the new round number: each branch sets tanks_alive to that round's count, calls createUnits at one or more spawn yards, and follows each call with an order at regionCenter of player_base.

The counts are written out per round rather than derived: round 2 is 4, round 3 is 5, round 4 is 6, and round 5 is 3, the last being the boss and two dreadnoughts. Writing them out is the right call for five hand-tuned rounds, and the wrong call for fifty.

t_tank_airdrop is the pressure valve. Every 1000 ticks, which is 50 seconds, it calls addResource for 100 gold and setUnitHp to put the player tank back to 600, its full value from the def override. Repairs also happen between rounds, in t_tank_kill. Without them a long arena becomes a war of attrition that only ends one way, because there is nothing in the map for the player to build or heal with.

How the match ends

The win is in t_tank_kill. After its round-escalation branch it asks a second question: tanks_alive == 0 and round == 5. When both hold it sets game_over, then issues victory for seat 0 and defeat for seat 1. That is the only victory for seat 0 anywhere in the map, and clearing round 5 is the only way to reach it.

The loss is in t_tank_clock, the once-a-second trigger that draws the leaderboard. Its check is unitProp for alive on entityByAid of player_tank, wrapped in a not. When the tank is gone it sets game_over, then issues defeat for seat 0 and victory for seat 1, and marks the quest failed.

game_over is a condition on t_tank_clock, t_tank_ai_hunt and t_tank_airdrop, so after either ending the hunt stops re-ordering and the airdrop stops repairing a tank that no longer exists. t_tank_kill is the one trigger without that condition, which is harmless because its body needs a hostile tank to die.

Run it yourself

An idle player tank loses this map, which makes it the easy branch to check:

npx tsx packages/modkit/src/cli.ts play public/maps/tanks-of-stone-showcase.json \
  --ticks 2400 --seed 1 --assert 'state=defeat' --assert 'var:round>1'
map      public/maps/tanks-of-stone-showcase.json (seed 1)
seats    0:dwarf/t0 (human, idle unless --drive) ✗  1:dwarf/t1  2:dwarf/t2
ended    defeat — winner none at tick 2061 (103s) [trigger]
vars     round=3, tanks_alive=3, tanks_destroyed=9, time_elapsed=103, game_over=true, spawned_group=[358]
commands 0 issued by --drive
fires    One-second Clock and Hull Check ×103, Hostile Tank Destroyed and Round Escalation ×36, AI Attack Move Towards Player ×26, Periodic Munition & Coolant Drop ×2, Map Initialization ×1
caps     0
broken   0
errors   0
asserts:
  PASS state=defeat  (actual: defeat)
  PASS var:round>1  (actual: 3)
RESULT   OK

Read winner none as correct rather than as a bug: the loss pairs defeat for seat 0 with victory for seat 1, and the seat the run is watching from lost. round=3 proves the escalation branch works, since an idle tank still killed nine hostiles and cleared two rounds on its own.

Validation reports seven warnings on this map, all of them building-terrain-mismatch: the town hall and six of the 26 barricades are placed on rock, which is not buildable ground, so the engine moves each to the nearest clear ground or does not place it. They are warnings, not errors, and the map plays. They are also a good example of why you check a hand-placed building field rather than eyeballing it.

One honest gap, unlike the survival, tower defence, castle fight and hero arena showcases: this map has no dedicated outcome test in the repository. Nothing drives a seat to the round 5 win and asserts it. The loss branch is the one the command above exercises, and the win branch has been played but is not pinned.

What to take from it

  • Write enemy movement as a periodic order. unitsOfPlayer with excludeBuildings, aimed at unitPoint of entityByAid, re-issued every few dozen ticks. With disableAi: true set, this is the whole opponent.
  • One entityByAid handle serves every trigger. player_tank is read by the hunt, the airdrop, the leaderboard rows, the between-rounds repair and the defeat check, and each of those would otherwise need its own way of finding the thing.
  • Gate an unfiltered unitDies on eventPlayer. One trigger can own every death on the map as long as its first action is an if on whose unit it was.
  • Patch one base def into a whole roster. Five dwarf_steam_tank overrides with different HP and range gave this map its entire cast, with art included.
  • Give the player a repair, and give it a schedule. setUnitHp on a periodic, plus another on round change, is the difference between an arena and a slow loss.

Changing it

This map has no generator. Nothing in tools/ writes public/maps/tanks-of-stone-showcase.json; the only references anywhere in the repository are test fixtures, the featured list and the perf fixture list. Like the Deep Delve showcase, it is hand-authored, pretty-printed JSON.

Two routes:

  • Open it in The Forge. File → Import from AI / JSON…, paste the file, and you get the unit and building placement tools plus Test Map (Ctrl+F9). This is the route to take for the barricade field, since 26 placements are much easier to nudge on a canvas than in a list of coordinates.
  • Edit the JSON. Reasonable for the round branches in t_tank_kill, and risky for terrain: the terrain layers are run-length encoded strings, and a hand-edited one can be the wrong shape and still load. This file once stored six 50-tile rows on a 48-wide map, and the right-hand arena wall was dropped unread. The sweep in src/game/map/mapValidation.terrainEncoding.test.ts now runs every shipped map through the same rule.

Either way, validate before you play:

npx tsx packages/modkit/src/cli.ts validate public/maps/tanks-of-stone-showcase.json

Where to go next