Map Editor Documentation

Walkthrough: Castle Fight

A read-through of the shipped Castle Fight showcase: buildings that spawn their own army, income on a timer, and a castle poll that pairs victory and defeat across four seats.

Genres · Page 37 of 49

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

Castle Fight: Iron vs Scrap

What it is made of

PieceValue
Size64, biome plains
Seat 0user, dwarf, team 0, 300 gold
Seat 1computer, dwarf, team 0, 300 gold, passive
Seats 2 and 3computer, goblin, team 1, 300 gold each, passive
RulesdisableAi, noStartingUnits on; standardVictory off; fog explored
BuildableSix spawner ids only, via allowedBuildings
PlacedTwo castles (castle_west, castle_east, 6000 HP each), three starting spawners for the bots, two miners for seat 0
Triggers9
Variables16
Regions7
Overrides6 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

TriggerWhen it firesWhat it does
cf_init Map InitializationmapInit, onceMarks 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 BriefingdialogButton, oncehideDialog
cf_spawner_ready Spawner FinishedbuildingComplete, filtered to the six spawner idsaddUnitToGroup on ready_spawners, plus a ping and a quest update
cf_clock One-second Clockperiodic, every 20 ticks, while not game_overCounts next_wave down and refreshes the counters, leaderboard rows and both castle bars
cf_income Income Tickperiodic, every 200 ticks, while not game_overSets 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 Spawnersperiodic, every 300 ticks, while not game_overFor 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 Waveperiodic, every 400 ticks, while not game_overThe wave. Described below
cf_bounty Kill BountyunitDiesremoveUnitFromGroup 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 Destroyedperiodic, every 10 ticks, while not game_overReads 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 1
map      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   OK

Read 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_ready does addUnitToGroup on buildingComplete; cf_wave does forEachUnit over 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, so cf_init unions unitsOfType with groupUnion to catch them.
  • Empty the group too. removeUnitFromGroup in cf_bounty keeps dead spawners out of the loop.
  • Branch on playerTeam, never on a seat number. One cf_wave body 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_income wraps its addResource in playerAlive, so an empty slot in a smaller lobby costs nothing.
  • Poll cheap things on a slow timer. cf_castle_watch runs 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.ts

tools/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