Map Editor Documentation

Walkthrough: Hero Arena

A read-through of the shipped champion arena: a custom event between scoring and winning, chat-driven save codes, and a bot loop written entirely in triggers.

Genres · Page 38 of 49

Champions of the Ring is a four-seat free-for-all where each seat owns one champion, farms the creeps that pour out of the middle, and hunts the other three. The question it asks, from the first kill to the last, is do I take the safe points or the risky ones?

Play it

Champions of the Ring

What it is made of

PieceValue
Size64, biome wasteland
Seat 0user, dwarf, team 0
Seats 1 to 3computer, goblin / ratmen / lizardmen, teams 1, 2 and 3, all passive
RulesdisableAi, noStartingUnits on; standardVictory off; fog off; allowedBuildings empty
PlacedFour neutral altars, one per shrine, and four champions, one per seat
Triggers11
Variables27
Regions6
Overrides16 units in defOverrides, no buildings

Every seat is its own team, so there are four sides and no allies. Nobody can build anything: allowedBuildings is empty and the only structures on the map are neutral.

The sixteen overridden units are twelve champions (three tiers for each of the four races) and four creeps. Champions here are unit clones, not heroes. They do not gain levels; they are replaced by a stronger clone when they ascend, which is a technique any map can use without touching the hero system at all.

The regions are four shrines, one per seat, the central pit the creeps spawn in, and ring, the arena floor.

How it works

TriggerWhen it firesWhat it does
arena_init Map InitializationmapInit, onceMarks seats 1 to 3 as bots, builds the leaderboard and counters, sets two quests, and opens the blessing dialog
arena_blessing Blessing ChosendialogButton on the blessing dialog, when that seat has not chosenStores the choice in blessing for that seat, hides the dialog and announces it
arena_blessing_timeout Blessing TimeoutatTick 400, onceCloses the dialog for anyone who did not choose
arena_clock One-second Clockperiodic, every 20 ticks, while not game_overCounts the creep wave timer down; counts each dead seat's respawn down and re-creates its champion at its shrine when it hits zero; heals and restores mana for any champion standing in its own uncontested shrine
arena_bots Bot Champions Thinkperiodic, every 30 ticks, while not game_overThe bot loop. Described below
arena_creep_wave Creeps Pour From the Pitperiodic, every 500 ticks, while not game_overRaises wave, then four times over creates wolves, trolls or ogres (by wave number) at a randomPointInRegion inside pit and attack-moves them toward one of the four shrines. Every fifth wave also spawns one Pit Lord
arena_death Scoring and Champion DeathunitDiesAwards points to the killer and raises a custom event. Described below
arena_check_score Check Score / Ascend / Winthe custom event check_scoreRefreshes that seat's leaderboard row, raises ascend if it has crossed a tier threshold, and ends the match if it has reached the target
arena_ascend Ascensionthe custom event ascendRaises tier, removes the old champion and creates the next-tier clone on the same spot
arena_save -save Champion CodeplayerChats, exact match on -savegenerateSaveCode for that seat's tier and score, shown as text
arena_load -load Champion CodeplayerChats, prefix match on -load verifySaveCode on the pasted code, and a small starting bonus if it passes

The technique worth stealing is the custom event. arena_death runs on every death in the match, and it is entirely about scoring: creep or champion, which blessing the killer picked, how many points that is worth. It does not know or care whether the match is over. When it has finished adding points it calls raiseEvent with the name check_score and the killer's seat as the value, and stops.

arena_check_score listens for that custom event, reads the seat out with eventValue, and owns every consequence of a score changing: the leaderboard row, the ascension thresholds (10 points at tier 1, 25 at tier 2), and the win.

Splitting them that way means "a thing happened" and "did that end the game" are separate triggers, and the second one has exactly one caller to audit. arena_load raises the same check_score event after granting its bonus, so a loaded code goes through the identical path with no duplicated logic.

Condensed to its three load-bearing actions, the scoring trigger looks like this. The real one wraps the award in conditions, because how many points a kill is worth depends on what died and which blessing the killer picked:

{
  "id": "arena_death",
  "name": "Scoring and Champion Death",
  "events": [{ "k": "unitDies" }],
  "actions": [
    {
      "k": "setVar",
      "name": "killer",
      "value": { "k": "unitProp", "unit": { "k": "eventOtherUnit" }, "prop": "owner" }
    },
    {
      "k": "addVar",
      "name": "score",
      "index": { "k": "var", "name": "killer" },
      "delta": { "k": "int", "v": 5 }
    },
    {
      "k": "raiseEvent",
      "name": "check_score",
      "value": { "k": "var", "name": "killer" }
    }
  ]
}

Chat is an input device. arena_save and arena_load are both playerChats triggers, one exact and one prefix. Typing -save calls generateSaveCode with that seat's tier and score; typing -load and a code runs verifySaveCode, which the trigger checks three ways before it grants anything: the code must be valid, it must belong to the seat that typed it, and the seat must not have loaded one already. The map adds a fourth check of its own, that the tick is under 1200, so a code cannot be pasted in mid-match. The bonus itself is clamped. Treat a save code as untrusted input, because it is.

The bots are triggers, not AI. arena_bots runs every 30 ticks with disableAi on, so nothing else is steering them. Per bot seat it flips a retreat flag from the champion's HP percentage, and if it is not retreating it walks the creep seat's units with forEachUnit over unitsOfPlayer, measures each with distance, keeps the nearest, then compares that against the rival champions that are alive and not sitting inside their own shrine (asked with groupHas over unitsInRegion). The winner of that comparison gets an order. It is nothing but nested conditions, and it plays a recognisable game.

How the match ends

arena_check_score ends it, and nothing else can. Inside it, guarded by not game_over and that seat's score being at least target, which the map initialises to 40:

  • setVar on game_over, so nothing fires again.
  • victory on the winning seat, k, taken from eventValue.
  • a forEachInt from 0 to 3 that issues defeat on every seat except k.

That loop is the whole reason the map works from any seat. Four sides means three losers, and each of them may be the seat somebody is watching from. A victory alone would end the match for exactly one of the four.

There is no defeat for running out of champions: a dead champion always respawns. The only ending is somebody reaching 40.

Run it yourself

npx tsx packages/modkit/src/cli.ts play public/maps/hero-arena-showcase.json \
  --ticks 8000 --seed 1
map      public/maps/hero-arena-showcase.json (seed 1)
seats    0:dwarf/t0 (human, idle unless --drive) ✗  1:goblin/t1  2:ratmen/t2 ✗  3:lizardmen/t3 ✗
ended    defeat — winner none at tick 3759 (188s) [trigger]
vars     champ=[0,102,0,130], tier=[2,3,2,1], score=[22,40,21,7], blessing=[1,1,1,2], respawn=[1,0,10,0], is_bot=[0,1,1,1], loaded=[0,0,0,0], target=40, wave=7, next_wave=13, killer=1, dead=8, k=1, j=3, i=3, hp_pct=70, game_over=true, spot={"x":49,"y":31}, spawned=[130], save_code="", load_code="", retreat=[0,0,1,0], bot_target=[0,114,0,51], u=123, best=51, best_d=1, d=28
commands 0 issued by --drive
fires    One-second Clock ×187, Bot Champions Think ×126, Scoring and Champion Death ×53, Check Score / Ascend / Win ×44, Creeps Pour From the Pit ×7, Ascension ×4, Map Initialization ×1, Blessing Timeout ×1
caps     0
broken   0
errors   0
RESULT   OK

The vars line is the readable part. score=[22,40,21,7] says seat 1 got to exactly 40 and the other three did not, and tier=[2,3,2,1] says three seats ascended at least once. Seat 0 was idle for the whole run and still finished on 22, which is the one number on that line worth watching if you retune the scoring.

ended defeat with winner none is the paired outcome again: seat 1 won, and the run reports what the watched seat saw. Ascension ×4 and Check Score / Ascend / Win ×44 are the two custom-event triggers, both reached only through raiseEvent.

What to take from it

  • Put a custom event between the thing that happened and the rule that reads it. arena_death scores and calls raiseEvent; arena_check_score listens with a custom event and decides. Two callers, one decision.
  • Pass the subject in the event's value. raiseEvent carries the seat, and the handler picks it up with eventValue, so the handler holds no state between calls.
  • Pair the outcome across every seat. A victory on the winner and a forEachInt loop issuing defeat on the rest. In a free-for-all, three of the four seats need the losing half.
  • Tiers beat levels when you want control. arena_ascend removes the champion and calls createUnits for the next clone at the same point. Twelve ids in defOverrides replace an entire progression system.
  • Validate chat input like input. arena_load requires verifySaveCode to pass, the code's seat to match eventPlayer, the seat to be unused, and tick to be under 1200, and it clamps the bonus. A save code is a string a player typed.
  • A bot can be a trigger. arena_bots is a periodic loop over unitsOfPlayer, distance, unitsInRegion and order, and it needs no engine AI behind it.

Changing it

This map is generated. Never hand-edit public/maps/hero-arena-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 hero arena definition begins around line 986. The champion tiers, the creep table, the blessings and all eleven triggers are written there in TypeScript, which is why the arrays are generated per seat rather than hand-copied four times.

Where to go next