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
What it is made of
| Piece | Value |
|---|---|
| Size | 64, biome wasteland |
| Seat 0 | user, dwarf, team 0 |
| Seats 1 to 3 | computer, goblin / ratmen / lizardmen, teams 1, 2 and 3, all passive |
| Rules | disableAi, noStartingUnits on; standardVictory off; fog off; allowedBuildings empty |
| Placed | Four neutral altars, one per shrine, and four champions, one per seat |
| Triggers | 11 |
| Variables | 27 |
| Regions | 6 |
| Overrides | 16 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
| Trigger | When it fires | What it does |
|---|---|---|
arena_init Map Initialization | mapInit, once | Marks seats 1 to 3 as bots, builds the leaderboard and counters, sets two quests, and opens the blessing dialog |
arena_blessing Blessing Chosen | dialogButton on the blessing dialog, when that seat has not chosen | Stores the choice in blessing for that seat, hides the dialog and announces it |
arena_blessing_timeout Blessing Timeout | atTick 400, once | Closes the dialog for anyone who did not choose |
arena_clock One-second Clock | periodic, every 20 ticks, while not game_over | Counts 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 Think | periodic, every 30 ticks, while not game_over | The bot loop. Described below |
arena_creep_wave Creeps Pour From the Pit | periodic, every 500 ticks, while not game_over | Raises 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 Death | unitDies | Awards points to the killer and raises a custom event. Described below |
arena_check_score Check Score / Ascend / Win | the custom event check_score | Refreshes 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 Ascension | the custom event ascend | Raises tier, removes the old champion and creates the next-tier clone on the same spot |
arena_save -save Champion Code | playerChats, exact match on -save | generateSaveCode for that seat's tier and score, shown as text |
arena_load -load Champion Code | playerChats, 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:
setVarongame_over, so nothing fires again.victoryon the winning seat,k, taken fromeventValue.- a
forEachIntfrom 0 to 3 that issuesdefeaton every seat exceptk.
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 1map 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 OKThe 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_deathscores and callsraiseEvent;arena_check_scorelistens with acustomevent and decides. Two callers, one decision. - Pass the subject in the event's value.
raiseEventcarries the seat, and the handler picks it up witheventValue, so the handler holds no state between calls. - Pair the outcome across every seat. A
victoryon the winner and aforEachIntloop issuingdefeaton the rest. In a free-for-all, three of the four seats need the losing half. - Tiers beat levels when you want control.
arena_ascendremoves the champion and callscreateUnitsfor the next clone at the same point. Twelve ids indefOverridesreplace an entire progression system. - Validate chat input like input.
arena_loadrequiresverifySaveCodeto pass, the code's seat to matcheventPlayer, the seat to be unused, andtickto be under 1200, and it clamps the bonus. A save code is a string a player typed. - A bot can be a trigger.
arena_botsis aperiodicloop overunitsOfPlayer,distance,unitsInRegionandorder, 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.tstools/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
- Walkthrough: Castle Fight shows the team version of paired outcomes, and an army built from buildings.
- Walkthrough: Tower Defense is the single-seat shape, with waves on a tick schedule.
- Players & Teams covers free-for-all seating and why a seat number should never be hardcoded.
- Triggers and the
Trigger Reference cover every node named
above, including
raiseEvent,generateSaveCodeandverifySaveCode. - Data Overrides explains the sixteen cloned units.