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
What it is made of
| Piece | What the file says |
|---|---|
| Map | 48 tiles square, wasteland biome, mapType of arena |
| Capabilities | meta.capabilities records legacy render targets; The Forge and custom matches now use 3D |
| Seat 0 | user, dwarf, team 0. Starts on 100 gold and owns the player tank |
| Seat 1 | computer, dwarf, team 1. Every hostile tank belongs to it |
| Seat 2 | computer, dwarf, team 2. Owns all 26 barricades and nothing else |
| Rules | disableAi: true, standardVictory: false, noStartingUnits: true, allowedBuildings empty |
| Placed | 27 buildings (seat 0's town hall, seat 2's 26 tank_barricade) and 4 units (seat 0's tank, seat 1's three scouts) |
| Script | 6 triggers, 6 variables, 7 regions |
| Data | 5 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
| Trigger | Fires on | What it does |
|---|---|---|
t_tank_init | mapInit, run once | Sets 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_close | dialogButton for tank_briefing, run once | One hideDialog |
t_tank_clock | periodic, every 20 ticks, while not game_over | Adds 1 to time_elapsed, redraws all five leaderboard rows, and runs the defeat check |
t_tank_ai_hunt | periodic, every 80 ticks, while not game_over | Re-orders every non-building unit of seat 1 to attack-move at the player tank's current position |
t_tank_airdrop | periodic, every 1000 ticks, while not game_over | Announces a drop, pays seat 0 100 gold, and sets the player tank back to 600 HP |
t_tank_kill | unitDies, unfiltered | Everything 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 OKRead 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
periodicorder.unitsOfPlayerwithexcludeBuildings, aimed atunitPointofentityByAid, re-issued every few dozen ticks. WithdisableAi: trueset, this is the whole opponent. - One
entityByAidhandle serves every trigger.player_tankis 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
unitDiesoneventPlayer. One trigger can own every death on the map as long as its first action is anifon whose unit it was. - Patch one base def into a whole roster. Five
dwarf_steam_tankoverrides with different HP and range gave this map its entire cast, with art included. - Give the player a repair, and give it a schedule.
setUnitHpon aperiodic, 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 insrc/game/map/mapValidation.terrainEncoding.test.tsnow 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.jsonWhere to go next
- Walkthrough: Deep Delve is the other hand-authored showcase, and it spawns its enemies from a timer instead.
- Walkthrough: Survival pairs trigger-driven waves with a build phase.
- The Genre Cookbook collects the shapes these walkthroughs read in full.
- Triggers and the Trigger Reference cover every node named above.
- Data Overrides is how one base def becomes five tanks.
- Performance and limits explains what
a
periodicgroup scan actually costs.