Castle Fight: Iron vs Scrap is a 2v2 lane battle where you never give an order in combat. You spend gold on buildings, each finished building sends one unit down the lane every wave, and the question the map asks is only ever this: what do you buy with the gold you have right now?
Play it
What it is made of
| Piece | Value |
|---|---|
| Size | 64, biome plains |
| Seat 0 | user, dwarf, team 0, 300 gold |
| Seat 1 | computer, dwarf, team 0, 300 gold, passive |
| Seats 2 and 3 | computer, goblin, team 1, 300 gold each, passive |
| Rules | disableAi, noStartingUnits on; standardVictory off; fog explored |
| Buildable | Six spawner ids only, via allowedBuildings |
| Placed | Two castles (castle_west, castle_east, 6000 HP each), three starting spawners for the bots, two miners for seat 0 |
| Triggers | 9 |
| Variables | 16 |
| Regions | 7 |
| Overrides | 6 buildings and 6 units in defOverrides |
All twelve ids the map fights with are clones. The six spawners are cloned from real barracks, archery ranges and workshops; the six units they emit are cloned from real units. Nothing about the genre needs new art.
The regions split into three jobs: west_build and east_build are the two
build zones, rally_west and rally_east are where reinforcements appear, and
castle_west and castle_east are the targets both armies walk to.
battlefield is the 20x20 middle, which no trigger reads.
How it works
| Trigger | When it fires | What it does |
|---|---|---|
cf_init Map Initialization | mapInit, once | Marks seats 1 to 3 as bots, calls setBuildableRegion once per seat, seeds ready_spawners, builds the leaderboard, two createUIFrame castle bars and two quests, and opens the briefing dialog |
cf_briefing_close Close Briefing | dialogButton, once | hideDialog |
cf_spawner_ready Spawner Finished | buildingComplete, filtered to the six spawner ids | addUnitToGroup on ready_spawners, plus a ping and a quest update |
cf_clock One-second Clock | periodic, every 20 ticks, while not game_over | Counts next_wave down and refreshes the counters, leaderboard rows and both castle bars |
cf_income Income Tick | periodic, every 200 ticks, while not game_over | Sets income to 20 plus twice the wave number, then forEachInt over seats 0 to 3 paying addResource gold to each seat that playerAlive says is present |
cf_bot_economy Bot Warlords Buy Spawners | periodic, every 300 ticks, while not game_over | For each bot seat under nine spawners, picks a building with mod, checks playerResource, and if it can afford it spends the gold and increments that seat's spawner count |
cf_wave Spawn Wave | periodic, every 400 ticks, while not game_over | The wave. Described below |
cf_bounty Kill Bounty | unitDies | removeUnitFromGroup on ready_spawners, then if the killer and the victim are enemies pays 3 gold and bumps that seat's leaderboard kill row |
cf_castle_watch Castle Destroyed | periodic, every 10 ticks, while not game_over | Reads each castle's alive flag and ends the match. Described below |
Two triggers are the whole genre. cf_spawner_ready fires on
buildingComplete and does one thing: addUnitToGroup, adding the finished
building to the unit group ready_spawners. cf_wave then runs forEachUnit
over that group every 400 ticks, and for each living spawner reads its owner and
its type id with unitProp, calls createUnits for the matching unit at an
offset from the building, and orders that unit to attack-move at the enemy
castle. Which castle it walks to is decided by playerTeam on the owner, not by
a hardcoded seat.
That is why you never place a single soldier in this map. The building is the army. Add a seventh spawner type and you add one branch inside the loop; you do not touch the map at all.
{
"id": "cf_spawner_ready",
"name": "Spawner Finished",
"events": [
{
"k": "buildingComplete",
"filter": { "unitTypeIds": ["cf_iron_hall", "cf_bolt_range"] }
}
],
"actions": [
{
"k": "addUnitToGroup",
"varName": "ready_spawners",
"unit": { "k": "eventUnit" }
}
]
}A pre-placed building never raises buildingComplete. The three spawners
the bots start with are written into the map already finished, so the event that
fills the group never fires for them, and without a fix they would have sat
there emitting nothing all match. cf_init therefore seeds the group directly,
with a setVar that unions six unitsOfType expressions together with
groupUnion. It is a one-line comment in the generator and an invisible,
silent bug if you miss it.
The group needs emptying as well as filling. cf_bounty runs on every
death and starts with removeUnitFromGroup, so a destroyed spawner leaves
ready_spawners the moment it dies. The loop in cf_wave also re-checks each
member with unitProp before spawning, which is belt and braces, and cheap.
The bots do not really build. cf_bot_economy spends their gold and increments
three counters, and the second half of cf_wave reads those counters and
creates that many units at the team's rally region. It is an economy simulated
in variables, which costs nothing and cannot get stuck behind a blocked
building site.
How the match ends
cf_castle_watch polls every 10 ticks, guarded by not game_over. It asks one
question with unitProp on entityByAid for castle_east, using the alive
property, and it has an else branch that asks the same question of
castle_west.
If the Scrap Keep is down, it sets game_over, then issues victory on
player 0 and player 1, and defeat on player 2 and player 3.
If the Iron Hold is down, the mirror: victory on players 2 and 3,
defeat on players 0 and 1.
Four actions per ending, not one. An outcome only resolves for the seat the
player is watching from, so the winning team's victory alone would leave a
human on the losing team sitting in a match that never ends. Pair both sides of
every outcome and the map is correct however the seats are filled.
There is a timeout of sorts, too: from wave 30 onward cf_wave takes 4 percent
off both castles every wave, so a stalemate resolves itself.
Run it yourself
npx tsx packages/modkit/src/cli.ts play public/maps/castle-fight-showcase.json \
--ticks 4000 --seed 1map public/maps/castle-fight-showcase.json (seed 1)
seats 0:dwarf/t0 (human, idle unless --drive) ✗ 1:dwarf/t0 ✗ 2:goblin/t1 3:goblin/t1
ended defeat — winner none at tick 2571 (129s) [trigger]
vars wave=6, next_wave=12, income=30, game_over=true, is_bot=[0,1,1,1], bot_halls=[0,2,2,2], bot_ranges=[0,1,1,1], bot_works=[0,1,0,0], kills=[0,13,10,9], killer=2, owner=3, i=3, pick=3, sp=5, ready_spawners=[4,5], last_spawn=[]
commands 0 issued by --drive
fires Castle Destroyed ×258, One-second Clock ×128, Kill Bounty ×32, Income Tick ×12, Bot Warlords Buy Spawners ×9, Spawn Wave ×6, Map Initialization ×1
caps 0
broken 0
errors 0
RESULT OKRead that output carefully, because two lines in it are the lesson.
ended defeat with winner none is correct. Seat 2 and seat 3 won, and
the run reports what the watched seat saw, which was a defeat. This is the
paired outcome working: had cf_castle_watch issued only the winners' half,
this line would read still running.
bot_halls=[0,2,2,2] shows the human seat at zero. Nobody was at the keyboard,
so seat 0 built nothing and seat 1 carried the team alone against two. The Iron
Hold fell on wave 6. As with any build-to-win map, a headless run proves it is
losable, not that it is winnable.
What to take from it
- Let an event fill a unit group, and a loop read it.
cf_spawner_readydoesaddUnitToGrouponbuildingComplete;cf_wavedoesforEachUnitover the same variable. Adding content means adding a branch, never editing the map. - Seed the group for anything placed in the editor. Pre-placed buildings
raise no
buildingComplete, socf_initunionsunitsOfTypewithgroupUnionto catch them. - Empty the group too.
removeUnitFromGroupincf_bountykeeps dead spawners out of the loop. - Branch on
playerTeam, never on a seat number. Onecf_wavebody serves four seats and both directions because it asks which team the owner is on. - Pay every seat that exists, not every seat you declared.
cf_incomewraps itsaddResourceinplayerAlive, so an empty slot in a smaller lobby costs nothing. - Poll cheap things on a slow timer.
cf_castle_watchruns every 10 ticks and reads two properties. That is twice a second for the whole match and it cost nothing measurable.
Changing it
This map is generated. Never hand-edit public/maps/castle-fight-showcase.json.
Edit the generator and re-run it:
npx tsx tools/generate_showcase_maps.tstools/generate_showcase_maps.ts builds three maps, and the Castle Fight
definition begins around line 488. The same file's header explains why the
unitDies triggers here carry no filter: each one is a single trigger serving
several kinds of death, so it branches inside its actions on eventPlayer and
eventString instead.
Where to go next
- Walkthrough: Tower Defense uses a wave timer instead of buildings, and one seat instead of four.
- Walkthrough: Hero Arena is the free-for-all shape, where the loop that ends the match is driven by a custom event.
- Players & Teams covers seats, teams and why pairing outcomes matters.
- Triggers and the Trigger Reference cover every node named above.
- Data Overrides explains the twelve cloned ids this map fights with.