<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://battlecode-underthec.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://battlecode-underthec.github.io/" rel="alternate" type="text/html" /><updated>2026-10-11T12:22:17+00:00</updated><id>https://battlecode-underthec.github.io/feed.xml</id><title type="html">UnderTheC</title><subtitle></subtitle><entry><title type="html">7. Postmortem and results</title><link href="https://battlecode-underthec.github.io/2026/10/11/7-results/" rel="alternate" type="text/html" title="7. Postmortem and results" /><published>2026-10-11T00:07:00+00:00</published><updated>2026-10-11T00:07:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/7-results</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/7-results/"><![CDATA[<p>We set a goal on 10-06: reach #10 on the competition server. We didn’t. Our best recorded rank was #16, the last one we recorded was #24, and we have no record of a final end-of-competition rank. This post is the scoreboard and what we make of it.</p>

<p>The main lesson is the plainest one. Throwing more tokens at the problem does win: our rating rose about linearly with the tokens we put in. The stall after 10-06 fits that, since rounds shrank from six workers to three or four agents and one session stopped at its weekly usage limit.</p>

<h2 id="the-ladder">The ladder</h2>

<p>The server rating is Elo-like and reset to 1500 when the 1.2.3 queen rules shipped on 10-01. These are the points we wrote down, with the bot that was live (bots are described in <a href="/2026/10/11/6-bots/">what the factory made</a>).</p>

<figure>
  <p><img src="/assets/img/ladder.svg" alt="Server rating from 10-04 to 10-09" loading="lazy" /></p>
  <figcaption>Server rating after the 10-01 reset, from the points we recorded.</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Rank</th>
      <th>Rating</th>
      <th>Live bot</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>10-01</td>
      <td>#72 of 947</td>
      <td>~1767</td>
      <td>arena-search-7 (v9)</td>
    </tr>
    <tr>
      <td>10-01</td>
      <td> </td>
      <td>1797</td>
      <td>gst-giant-2 (v10), then reset to 1500</td>
    </tr>
    <tr>
      <td>10-04</td>
      <td>#35</td>
      <td>1832</td>
      <td>the teammate’s queen-ring17</td>
    </tr>
    <tr>
      <td>10-04</td>
      <td> </td>
      <td>1977</td>
      <td>ring-plus-4’s window (from 1831)</td>
    </tr>
    <tr>
      <td>10-05</td>
      <td>#22 → #26</td>
      <td>2035 → 2012</td>
      <td>ring-sched-8, then ring-sched-17</td>
    </tr>
    <tr>
      <td>10-06</td>
      <td>#31 → #28 → #22 → <strong>#16</strong></td>
      <td>2032 → 2038 → 2099 → <strong>2139</strong></td>
      <td>ring-contest-1, then ring-sprint-1</td>
    </tr>
    <tr>
      <td>10-07</td>
      <td>#19 → #23</td>
      <td>2146 → 2131</td>
      <td>ring-sprint-1 (#10 was 2270)</td>
    </tr>
    <tr>
      <td>10-07/08</td>
      <td>#28 → #29 → #27</td>
      <td>2112 → 2104 → 2120</td>
      <td>ring-sprint-1, then ring-field-3</td>
    </tr>
    <tr>
      <td>10-08</td>
      <td>#35</td>
      <td>2095</td>
      <td>ring-field-3</td>
    </tr>
    <tr>
      <td>10-09</td>
      <td>#29 → <strong>#24</strong></td>
      <td>2150 → <strong>2194</strong></td>
      <td>ring-sealed-2, ring-shield-3, ring-shield-10</td>
    </tr>
  </tbody>
</table>

<p>Two caveats sit under that table. First, the rating ran about 90 above our measured, rating-adjusted performance: at #16, ring-sprint-1 performed at 2046. Second, several jumps were not the bot. ring-sealed-2’s +78 came from opponents’ ratings drifting up; on 17 common opponents it won exactly as many games as the previous bot’s rates predicted. By our own measurements the real gains all came between 10-04 and 10-06 (see <a href="/2026/10/11/6-bots/">what the factory made</a>). After that the ladder mostly wandered between 2100 and 2200. #1 (aAah) was at 2526 on 10-07.</p>

<h2 id="the-sprint-tournament">The sprint tournament</h2>

<p>On 10-01 the server ran a sprint tournament: 591 teams, best of 7, on 7 new maps (Australia, Islands, Maze, Stripes, Tower Defense, Around UNSW, weakhold). It ran under the old 1.2.2 rules, and our live bot was v11 (gst-search-4). We were seeded #67, won 5 matches, and lost the round of 16 3-4. Over the tournament we went 31-11 in games, worst on Tower Defense (1-4). Cutlery beat PPP 4-3 in the final.</p>

<p>A strategist went through 140 tournament replays afterwards. Winners covered the map faster: the winner had visited more of the board in 94 of 112 games, and our heads had covered 23% by r100, the lowest of the 17 teams measured. On Tower Defense we never left the home box. Winners also started consolidating 50-100 rounds before us.</p>

<h2 id="the-numbers">The numbers</h2>

<p>The arena played 291,668 games between 680 submissions, 441 of them from the agents. Before it, the local league had played 170,039 matches between 206 variants. The agents left 545 worker briefs and 78 strategist files behind.</p>

<p>Commit volume traced the method. It was heaviest in the first two days, with six workers on 20-minute tasks in the local league; it fell from 10-01 to 10-03 while long-running teams worked and the arena was built; it rose again from 10-04 with loss analysis, strategists and short fresh workers.</p>

<h2 id="what-worked">What worked</h2>

<ul>
  <li><strong>Real games as the signal.</strong> Every durable gain was found by analysing server losses, then making one change. The ring line’s climb (#35 to #16 in two days) came out of that loop.</li>
  <li><strong>Short workers with one change each, judged externally.</strong> Lines climbed by stacking single changes that beat their parent: gst went 15 → 23 → 27 → … → 131, ring-plus 1 → 4 → 7 → 9 → 16 → 19 → 21.</li>
  <li><strong>Taking a teammate’s bot seriously.</strong> queen-ring17 was better on the server than anything the agents had made, and building on it was the biggest single step.</li>
  <li><strong>Fixing the measurement before trusting it.</strong> Two bugs made early results worthless: the sim seeded pearls differently from the judge, and a compiler flag made native builds diverge from the wasm judge. Finding them changed which bots we believed in.</li>
  <li><strong>Fresh maps as a check.</strong> Without generated maps we would have shipped the centre rush as a general gain.</li>
</ul>

<h2 id="what-didnt">What didn’t</h2>

<ul>
  <li><strong>The arena drifted from the server.</strong> Its server suite had 2 fixed seeds per map and its pool was our own bots, so queen-guard-7 and ring-plus-4 looked better at home than on the ladder.</li>
  <li><strong>Consolidation.</strong> Every attempt to feed one champion was neutral or worse in every line. The gap was always economy before r300.</li>
  <li><strong>Long-running teams.</strong> In round 9 their contexts grew and they drifted; about 15 verdicts landed inside noise.</li>
  <li><strong>Per-round merges.</strong> Bundling a round’s changes into one draft mixed effects; we reversed it on 10-06.</li>
  <li><strong>The work after 10-06.</strong> From 10-07 to 10-08 the ladder stalled around 2100 and we reset the orchestrator to its 10-06 instructions. Most of the ring-field, ring-sealed and ring-shield changes measured as flat on the server.</li>
</ul>

<h2 id="what-we-would-do-differently">What we would do differently</h2>

<ul>
  <li>Judge on the server, per map and rating-adjusted, from the first day, and treat our own arena as a filter only. That became the written rule only on 10-04, after queen-guard-7.</li>
  <li>Put non-family bots in the arena pool early. spar-field-2 beat its parent 67-45 but did worse against non-ring bots; a pool of our own relatives rewards beating our own habits.</li>
  <li>Spend more of the effort on the mid-game army, which the eval crawl pointed at on 10-05.</li>
  <li>Test every opening on generated map families before reading an arena gain.</li>
</ul>

<h2 id="whats-left">What’s left</h2>

<p>The arena is kept as a <a href="https://battlecode-underthec.github.io/arena/">static snapshot</a> where any game re-runs in the browser, and the code is public under <a href="https://github.com/Battlecode-UnderTheC">Battlecode-UnderTheC</a>: <a href="https://github.com/Battlecode-UnderTheC/syeyoung-tokens">syeyoung-tokens</a> for the bots, the orchestrator instructions and the generation reports, <a href="https://github.com/Battlecode-UnderTheC/tokenz-arena">tokenz-arena</a> for the arena and the eval model, and <a href="https://github.com/Battlecode-UnderTheC/syeyoung-rl">syeyoung-rl</a> for the RL attempt.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Where UnderTheC ended up on the ladder and in the sprint tournament, the numbers behind it, and what we would change.]]></summary></entry><entry><title type="html">6. What the factory made</title><link href="https://battlecode-underthec.github.io/2026/10/11/6-bots/" rel="alternate" type="text/html" title="6. What the factory made" /><published>2026-10-11T00:06:00+00:00</published><updated>2026-10-11T00:06:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/6-bots</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/6-bots/"><![CDATA[<p>Over twelve days the agents produced 322 variant directories locally and 680 arena submissions under 258 bot names. This post follows the lines that mattered, in rough order, and keeps two scores that often disagreed: the arena (our judge, our bots) and the competition server (everyone else).</p>

<h2 id="strategy-v1-and-the-gst-line">strategy-v1 and the gst line</h2>

<p><strong>strategy-v1</strong> (09-28) was a hand-written C++ bot from the RL repo. Its ideas: explore, gather pearls with many small dragons, split from length 4, trade head-to-head only with an enemy at least as long, and late in the game feed workers into the leaders. It beat the RL policy 79-1-0 on 40 pool maps and 22-0 on the official maps, and became server v3.</p>

<p><strong>gather-split-trade (gst)</strong> was the first line the league bred from it, and it ran for about 130 generations. Its early gains were about consolidation: the team agreed on one champion over sonar and fed it late in the game.</p>

<p>Corridor farming was syeyoung’s idea (g20). Short “farmer” dragons claimed corridor bed farms over sonar and stayed small, cycling the beds. gst109 did it heavily (600-1050 farmer-eaten pearls a game) and did not grow the team at all. The real opponent out-ate us 1.22x in corridors and 1.30x in open ground: corridors were no special edge.</p>

<p>What did survive was dead-end pricing. Once the sim used the judge’s pearl seeding, most old “wins” re-tested as coin flips. Only gst131’s change held up (57%): charge every dead end a flat cost of 3.0, dropping the expected-loss model, which halved dead-end entries. gst131 went up as server v8 on 09-30.</p>

<h2 id="arena-search-7-v9-and-its-0-5">arena-search-7 (v9) and its 0-5</h2>

<p>In the local arena rounds (09-29 to 10-02) six long-running Opus teams wrote bots, mostly from scratch. The search team’s <strong>arena-search-7</strong> used a per-dragon lookahead (depth 8) and finished the round-robin #1 at 62.2%, beating v8 184-115 on seeds it had never seen. It became v9 on 09-30.</p>

<p>Its first server battle was 0-5. We had picked that match against the leaderboard #1, so it said more about the #1 than about v9. But the replays showed something we had thrown away. The #1 farmed dead-end hot beds (beds that refill within about 3 rounds) with a tip split: eat up a dead end, split at length 4-5 so the rear half walks out. Hot-bed pearls went 150 to 6 on Autarky. arena-search-7’s room check kept it out of dead ends entirely, so the from-scratch bot had lost the corridor economy gst already had. Overall v9 went 112-0-86 and sat at #72 of 947, about 1767.</p>

<p>The rule that came out of it: build on gst131 and add one radical behaviour, no from-scratch bots.</p>

<h2 id="gst-giant-gst-search-and-the-123-queens">gst-giant, gst-search and the 1.2.3 queens</h2>

<p>That rule produced two quick server versions on 10-01. <strong>gst-giant-2</strong> was gst131 plus a rollout judge (with a portal fix) from the giant team; it went 40-0-35 as v10 and the rating rose 1767 to 1797. <strong>gst-search-4</strong> (v11) put arena-search-7’s lookahead on gst’s plain late moves. That lookahead gained on all three gst bases it was added to.</p>

<p>Then the game changed. unswbc 1.2.3 (“Slay Queen”, about 18:15 on 10-01) made dragon id 0/1 a queen: at r500 the side with a living queen wins before longest dragon is even checked. Sprints got cheaper (the first ceil(L/4) steps free) and every rating reset to 1500. In the first 20 new-rules server games almost every queen died, median around r50.</p>

<p><strong>gst-queen-1</strong> (v12) adapted gst-search-8 by making the queen the only champion. When she died, nobody fed anyone. The fix was an “heir champion”, which became gst-queen-3 (v13).</p>

<p>The <strong>queen-guard</strong> line came from the giant team’s survival core. The first version kept the queen alive in 21-30% of games instead of 8%, yet lost overall (66-74) because the guard cost early economy. After several rounds of fixes, queen-guard-7 was #1 on the arena and the arena’s gate auto-submitted it on 10-02. On the server it went 60-0-73. The arena and the ladder disagreed loudly for the first time.</p>

<p><strong>codex-queen-value-1</strong> came from Codex runs on separate branches (10-02 to 10-04). It was queen-guard-2 with queen length weighted 3x from r300, and reached arena #3 at 1543. Its lesson was how it beat us: it fed its queen and grew her long, while our heir feeding of a non-queen dragon halved our material after r320. When both queens survive, the longer queen wins.</p>

<h2 id="queen-ring17-and-the-ring-line">queen-ring17 and the ring line</h2>

<p>The bot that reset our direction was a teammate’s. bu-gye-jeong’s <strong>queen-ring17-first-then-grow-1</strong> kept gst’s economy, parked the queen in a 6-cell ring, and grew her late (she leaves at r430, nearby donors feed her from r450). It was 54-19 on queen-decided server games and was live at 77-0-75 when round 10 started on 10-04, with the team at #35, 1832. We imported it unchanged; each line below is one defining change on the previous head.</p>

<figure>
  <p><img src="/assets/img/ring-lineage.webp" alt="The ring lines in the arena lineage view" loading="lazy" /></p>
  <figcaption>The ring lines in the arena’s lineage view. Each line branched off the one above it; a version’s colour shows how it did against its parent (green beat, red lost, tan tied, faded within noise).</figcaption>
</figure>

<ul>
  <li><strong>ring-plus</strong> (10-04): first a forage opening (a short queen with 2 or fewer starters forages and splits before parking; ring-plus-1), then hunger-scaled exploration for workers before r150 (ring-plus-4). ring-plus-4 beat its parent 52-26 in the arena and was auto-deployed, then went 45-41 on the server, exactly at Elo expectation. On the arena’s server suite (2 fixed seeds per map) it had beaten ring17 50-18; elsewhere 26-24. Forage was real on 2-starter maps; hunger exploration hurt on 3+-starter maps. Measured on real opponents’ ratings, the line was still worth +80-90 Elo over ring17.</li>
  <li><strong>ring-sched</strong> (10-04 to 10-05): team task scheduling, our direction. ring-sched-1 beat its parent 50-28. ring-sched-8 added a race to the geometric centre’s bed cluster and performed at 2091 on the server, about +70-100 over ring-plus-9; the team peaked at #18, 2054 during its run. On 40 freshly generated anti-centre maps, though, the rush lost 64-96 to the same code without it: it was fitted to the maps we knew, and from then on every opening idea had to pass mapgen families. The rush-free merge ring-sched-17 beat ring-sched-8 67-45 and went live; on the server it went 29-43.</li>
  <li><strong>ring-contest</strong> (10-05): in the opening, contest food we reach first and split on dense food. 63-49 over ring-sched-17; live from 10-05.</li>
  <li><strong>ring-sprint</strong> (10-06): free sprints for non-queen dragons of length 5+. 61-51 over ring-contest-1, live from 10-06. The ladder went #31 2032 → #22 2099 → <strong>#16 2139</strong>, our best recorded rank. The server analysis then measured ring-sprint-1 at 2046 against ring-contest-1’s 2035: the sprints netted zero, because more of our 5+ dragons got killed.</li>
  <li><strong>ring-field</strong> (10-07): a committed opening that sends starters to the bed fields. ring-field-3 won 74.5% against the 10 non-ring arena bots and went live; on the server it performed the same as ring-sprint-21 (2071 v 2082).</li>
  <li><strong>ring-sealed</strong> (10-08): if the queen’s pocket is too small to win the queen race, play for longest dragon instead, plus a fix for a share discount that let any ally beside a kelp wall “claim” the whole field. ring-sealed-2 was auto-deployed on 10-09 and the team climbed from #35 to #29, but the server analysis put the gain down to opponent ratings drifting up.</li>
  <li><strong>ring-shield</strong> (10-09): a lunge guard for late big dragons, then a walled-bastion fix, then, after we allowed a soft centre preference, a port of the old centre rush with exits. ring-shield-10 won Trophy 16-0 in its arena games but lost Default 3-13. We uploaded it; the latest recorded ladder position was #24, 2194.</li>
</ul>

<p>Two side bots belong here. <strong>spar-field</strong> was a sparring partner: every arena opponent was a home-bound relative of ours, so spar-field played the top teams’ opening. spar-field-2 beat its parent 67-45 but was worse than ring-sprint-21 against non-ring bots (61.7% v 71.8%): its edge was only over our own family. <strong>syeyoung-bot</strong> was syeyoung’s own from-scratch bot, 36 commits on 10-08 and 10-09, written to try syeyoung’s own strategy in the hope that it would work well. Its plan: spread fast, harvest beds into children, children spread to new beds, the rest fight. Its notes say v1 explored and controlled more of the board than ring-field-3 but lost duels (no combat, the queen dies). We have no recorded arena verdict for the final version; syeyoung’s summary is that it didn’t work.</p>

<h2 id="what-the-lines-taught-us">What the lines taught us</h2>

<p>These held up across lines:</p>

<ul>
  <li><strong>Economy before r300 decides games; consolidation never paid.</strong> Every delivery mechanism we tried was neutral or negative on held-out seeds. Champions grew by eating, not by being fed.</li>
  <li><strong>Many short dragons early.</strong> On the solo benchmark, splitting at 4 reached 90% coverage at r124 against r177 for splitting at 12. Cutting splits to reduce churn lost (ring-longmid-1 25-42): short-lived children still take pearls and space first.</li>
  <li><strong>Top teams win on mid-game army, not queen safety.</strong> Their queens died more often than ours and they still won; see <a href="/2026/10/11/4-metrics/">the metrics post</a>.</li>
  <li><strong>The arena overrated what it was fitted to.</strong> Two fixed seeds per map and a pool of only our own bots rewarded beating our own habits: ring-plus-4 and queen-guard-7 looked better at home than on the ladder, and spar-field-2’s edge was only over our family.</li>
</ul>

<p>What moved the server rating, by our own rating-adjusted measurements: switching to the teammate’s ring bot, the ring-plus line (+80-90 over ring17), and ring-sched-8 (+70-100 over ring-plus-9). The sprints, the sealed queen and most work from 10-07 on measured as flat, whatever the ladder did.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[The bot lines we bred between 09-28 and 10-09, what each one did, and which of them moved the server rating.]]></summary></entry><entry><title type="html">5. The LLM loop up close</title><link href="https://battlecode-underthec.github.io/2026/10/11/5-llm-loop/" rel="alternate" type="text/html" title="5. The LLM loop up close" /><published>2026-10-11T00:05:00+00:00</published><updated>2026-10-11T00:05:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/5-llm-loop</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/5-llm-loop/"><![CDATA[<p>From 09-28 to 10-09 almost all of our bot code was written by Claude Code subagents, coordinated by one long-running Claude Code session that we called the orchestrator. The paper trail is in syeyoung-tokens: 50 generation reports (<code class="language-plaintext highlighter-rouge">league/generations/g*.md</code>), 545 filled-in briefs, 78 strategist proposal files, and <code class="language-plaintext highlighter-rouge">ORCHESTRATOR.md</code>, the instruction file a fresh orchestrator session reads before it does anything. This post is about how that loop worked and how it broke.</p>

<h2 id="the-roles">The roles</h2>

<p>There were four kinds of agent by the end.</p>

<ul>
  <li><strong>The orchestrator</strong> launched the other agents, verified every number they claimed, promoted drafts, kept the generation reports and <code class="language-plaintext highlighter-rouge">league/KNOWLEDGE.md</code> (735 lines by the end) up to date, and handled submissions. It was not allowed to design strategy.</li>
  <li><strong>Analysts</strong> (Opus, read-only) diagnosed server and arena losses from replays and wrote a ranked <code class="language-plaintext highlighter-rouge">REPORT.md</code> per round. Only they looked at images (rendered filmstrips).</li>
  <li><strong>Strategists</strong> (2-3 per round, Opus) read the analysis and <code class="language-plaintext highlighter-rouge">KNOWLEDGE.md</code> and each proposed ranked single changes, each with its evidence, its base bot and how to tell whether it worked. They wrote no code.</li>
  <li><strong>Workers</strong> (Opus 5.5) each made one change to one bot, tested it, submitted it to the arena and reported back.</li>
</ul>

<figure>
  <p><img src="/assets/img/loop.svg" alt="The agent loop" loading="lazy" /></p>
  <figcaption>One round, from 10-04 on.</figcaption>
</figure>

<p>None of this was the original design. On 09-29 the orchestrator was writing every task focus itself, and we pointed out that all the strategy was coming from it. Strategists followed, and the orchestrator was limited to coordinating. On 09-30, when it started digging into a new server battle itself, it was told that analysis wasn’t its job either. The orchestrator’s notes record why: a single view narrows the search and carries its mistakes through whole generations. The swapped-sides misread in <a href="/2026/10/11/2-mass-codegen/">post 2</a> was one such mistake: it drove three tasks.</p>

<p>Workers started on a mix of models. On 09-29 we moved them all to Opus 5.5 because the Sonnet and GPT workers often shipped only part of the brief, misreported results against a parent, or shipped a no-op as an improvement. Strategists ran on a mix of models until our routes to the GPT models stopped working on 09-30; from strategist round 11 we made them Opus-only too.</p>

<h2 id="a-brief-a-worktree-one-change">A brief, a worktree, one change</h2>

<p>Every worker started with no context. Its prompt was a template with the blanks filled in, written to a file so it survived restarts. The late template, <code class="language-plaintext highlighter-rouge">league/BRIEF-ARENA.md</code>, opens like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You make **one** change to one of our bots for UNSW Battlecode "dragons" and get it judged
by our team arena. You start with no context, so this brief is everything. Read it in full,
then follow it.
...
- **Base:** {base} (arena id `{base_id}`, line `{line}`)
- **Change:** {change}
- **Evidence it answers:** {evidence}
- **How to tell if it worked:** {success}
...
2. Make the ONE change. Don't sweep parameters or bundle a second idea.
</code></pre></div></div>

<p>The rest covered light arena duels, submitting with <code class="language-plaintext highlighter-rouge">--parent</code> set to the bot the code was built on, checking the draft’s own arena games for the targeted weak spot, and a 12-line report. Nearly every rule in it came from a correction.</p>

<p><code class="language-plaintext highlighter-rouge">scripts/new-task.sh</code> gave each task its own git worktree and branch, with the base bot copied into <code class="language-plaintext highlighter-rouge">variants/&lt;name&gt;/</code>. Workers could write only that directory. The orchestrator checked that each diff touched nothing else before merging. Six workers ran at once on one laptop.</p>

<p>One change per task was the point. The cleanest signal we ever had was a child against its parent, and a verdict on two changes at once tells you nothing about either.</p>

<h2 id="verdicts">Verdicts</h2>

<p>How a change was judged moved through five stages: league Elo with a hidden holdout, then judge-sandbox duels, then round-robins on held-out seeds, then the arena’s <code class="language-plaintext highlighter-rouge">beats_parent</code> plus the eval P(win) from <a href="/2026/10/11/4-metrics/">the metrics engine</a>, and finally server per-map rating-adjusted Elo.</p>

<p>In the arena era, a draft played its parent and the top 3 on several map suites, and <code class="language-plaintext highlighter-rouge">beats_parent</code> was simply true when it won more games against its parent than it lost. Promotion required it. That rule alone was too loose: 78 games is about ±9 wins, so the orchestrator was told to treat 38-40 or 41-37 as a tie and ask for more games. Round 9 showed what happens otherwise: a line can climb by three promotions that are all inside noise.</p>

<h2 id="how-the-method-changed">How the method changed</h2>

<ul>
  <li><strong>g1-g25 (09-28 to 09-29): local league, generations.</strong> Six tasks per generation, each tagged as a mutate, crossover, fresh or tweak operator, rated by a local Bradley-Terry league. It soon became continuous at our request: a very short loop, tasks of about 20 minutes, a free slot refilled the moment a result came in. The gather-split-trade line climbed this way to gst131.</li>
  <li><strong>Rounds 1-8 (g26-g33, 09-29 to 10-02): long-running teams.</strong> Six Opus teams (search, swarm, giant, hunter, territory, free), mostly from scratch. We suspected that losing context between tasks limited how far an agent could improve a bot, and long-running teams kept theirs. arena-search-7 topped our round-robin at 62.2% and became v9, then went 0-5 against the server’s #1, having lost the corridor farming gst had built up. The rule that came out of it: build on gst131 and add one radical behaviour; no from-scratch bots.</li>
  <li><strong>Round 9 (g34, 10-02 to 10-04): three long-running teams</strong> on the new arena, each owning a line. Their contexts grew long, they drifted, and about 15 verdicts landed inside noise.</li>
  <li><strong>Round 10 onward (g35, from 10-04): short fresh workers.</strong> Server and arena losses go to an analyst, the analysis goes to 2-3 strategists, and about six fresh one-change workers build on the line head. ring-plus went 1 → 4 → 7 → 9 → 16 → 19 → 21 this way, and the ladder went from #35 at 1832 on 10-04 to #16 at 2139 on 10-06.</li>
  <li><strong>Per-round merges, 10-05 to 10-06.</strong> For a day each round’s winning changes were bundled into one submission, so that each round’s improvements would stack. On 10-06 we reversed it: one change at a time on the head, with ports of other winners as ordinary single-change tasks.</li>
  <li><strong>After 10-06: stall.</strong> A deep analysis, 3-4-agent rounds, planner stages and numeric bars; the ladder sat around 2100.</li>
  <li><strong>The reset (10-08):</strong> we told the orchestrator to drop every instruction given since 10-06 and work the way it had up to then. <code class="language-plaintext highlighter-rouge">ORCHESTRATOR.md</code> and the brief template went back to their end-of-Oct-6 text. On 10-09 we stopped all workers, mid-task, with four drafts unfinished.</li>
</ul>

<p>In parallel, from 10-02 to 10-04, we ran Codex on separate branches, to get more tokens working on the problem. It made disciplined single-change queen tweaks and rejected its own failures honestly; codex-queen-value-1 reached #3 in the arena at 1543.</p>

<h2 id="rules-from-corrections">Rules from corrections</h2>

<p>Each of these is in the memory notes with the date it started. The ones that changed the most:</p>

<ul>
  <li><strong>No map hardcoding</strong> (09-30). A worker’s bot picked its play mode from a table of known map sizes, and the orchestrator had accepted it, asking only for a fallback. We called it what it was, a hardcoded map. From then every brief banned map names, exact sizes and lookup tables, and every new threshold had to name the observed cause it responds to.</li>
  <li><strong>Full brief at launch, no steering</strong> (10-05). The orchestrator had been messaging running analysts with its own conclusions (“our queen never splits on Colosseum”), which they then dutifully confirmed. Mid-run messages became limited to new evidence or a rule change from the user, and strategists started only after the analyses finished.</li>
  <li><strong>No unrequested builds</strong> (10-05). Asked to watch how games played out, the orchestrator built a text replay watcher, extended it, and rewrote the briefs to require it, none of which had been asked for.</li>
  <li><strong>Orchestrate without asking</strong> (10-04). The orchestrator kept asking permission for routine steps such as starting an analysis; managing agents was its job, so it stopped asking.</li>
  <li><strong>Line names describe the mechanism</strong> (10-05): ring-contest, not ring-next, and not a name borrowed from the user’s example when the code did something else.</li>
  <li><strong>Give an idea 2-3 tries if its target moves</strong> (10-05), because single noisy pass/fail verdicts were throwing away partial successes.</li>
</ul>

<h2 id="failures-and-fixes">Failures and fixes</h2>

<table>
  <thead>
    <tr>
      <th>What went wrong</th>
      <th>What fixed it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>From-scratch v9 lost behaviours the gst line had</td>
      <td>Build on gst131, add one behaviour</td>
    </tr>
    <tr>
      <td>Round-9 teams drifted inside noise</td>
      <td>Short fresh workers, one change each</td>
    </tr>
    <tr>
      <td>The arena overrated ring-plus-4, mostly on its 2-seed server suite</td>
      <td>Read verdicts split by suite</td>
    </tr>
    <tr>
      <td>A centre rush won on known maps and lost on generated anti-centre maps</td>
      <td>Test openings on <code class="language-plaintext highlighter-rouge">tools/mapgen</code> families before trusting arena gains</td>
    </tr>
    <tr>
      <td>The cloud session running 10-06/07 stopped at the weekly usage limit</td>
      <td>A “State at stop” section in <code class="language-plaintext highlighter-rouge">HANDOFF.md</code> for the next session</td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><summary type="html"><![CDATA[Who did what in our agent loop (orchestrator, analysts, strategists, workers), what a brief looked like, how verdicts were judged, and the rules we added each time it went wrong.]]></summary></entry><entry><title type="html">4. Metrics engine and probability analysis</title><link href="https://battlecode-underthec.github.io/2026/10/11/4-metrics/" rel="alternate" type="text/html" title="4. Metrics engine and probability analysis" /><published>2026-10-11T00:04:00+00:00</published><updated>2026-10-11T00:04:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/4-metrics</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/4-metrics/"><![CDATA[<p>A dragons game lasts 500 rounds, and a win-loss line throws almost all of it away. By early October our verdicts were 78-game draft runs in <a href="/2026/10/11/3-arena/">the arena</a>, and 78 games is about ±9 wins: a 41-37 result told us nothing. On 10-02 we added a metrics engine to tokenz-arena (<code class="language-plaintext highlighter-rouge">arena/metrics/</code>) and an eval bar on top of it. We wanted agents to see how a bot did in the early, mid and end game, not only whether it won: we think the game splits into those phases and that each of them matters. Our own notes later recommended the eval as “a lower-variance signal” than W-L, and that is how it ended up being used: to read a game before it ended, and to find the round where it turned.</p>

<h2 id="from-replay-to-numbers">From replay to numbers</h2>

<p>Every game the arena played left a <code class="language-plaintext highlighter-rouge">.replay</code> file. The metrics code decodes it and replays the game round by round in <code class="language-plaintext highlighter-rouge">state.py</code>. Every 10 rounds, and at the last round, it calls every registered metric once per team with the board state and cached helpers (BFS distances from each team’s heads, running counters for pearls, deaths, kills and splits).</p>

<p>A metric is a decorated Python function in <code class="language-plaintext highlighter-rouge">arena/metrics/plugins/</code>; dropping a new file in that folder registers it. The one that mattered most was <code class="language-plaintext highlighter-rouge">board_control</code>: the share of all tiles that this team’s heads reach before the enemy’s (BFS, bodies ignored). The rest were counts (<code class="language-plaintext highlighter-rouge">dragons</code>, <code class="language-plaintext highlighter-rouge">log_dragons</code> = log(1 + dragons alive), <code class="language-plaintext highlighter-rouge">total_length</code>, <code class="language-plaintext highlighter-rouge">longest</code>, <code class="language-plaintext highlighter-rouge">pearls_eaten</code>, <code class="language-plaintext highlighter-rouge">deaths</code>, <code class="language-plaintext highlighter-rouge">kills</code>, <code class="language-plaintext highlighter-rouge">splits</code>), the queen (<code class="language-plaintext highlighter-rouge">queen_alive</code>, <code class="language-plaintext highlighter-rouge">queen_length</code>, plus <code class="language-plaintext highlighter-rouge">queen_room</code>, <code class="language-plaintext highlighter-rouge">queen_danger</code> and <code class="language-plaintext highlighter-rouge">queen_share</code> for how a bot handles her) and exploration (<code class="language-plaintext highlighter-rouge">tiles_visited</code>, <code class="language-plaintext highlighter-rouge">tiles_seen</code>).</p>

<p>The output is one small JSON per game: each metric’s series for both teams, plus how and when each queen died. Changing a metric means bumping its version; a background task then recomputes older games.</p>

<p>Python stayed the reference. On 10-03 we added <code class="language-plaintext highlighter-rouge">native/metrics</code>, a C++ twin of every plugin, about 50x faster (20-30 ms a game on one core). The arena uses the binary only when it has every registered metric at the same version, and a parity script must report every replay identical; a new metric runs in Python until someone ports it.</p>

<h2 id="the-eval-pa-wins">The eval: P(A wins)</h2>

<p>The eval is a derived metric: it takes the per-team values at a round and returns the chance that team A wins. It is a logistic model on five differences:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>z(r) = w1(r)·Δlog(1+dragons) + w2(r)·Δboard_control + w3(r)·Δlongest
     + w4(r)·Δqueen_alive   + w5(r)·leader

P(A wins at round r) = 1 / (1 + e^(-z(r)))
</code></pre></div></div>

<p>Each Δ is A minus B. <code class="language-plaintext highlighter-rouge">leader</code> is +1 or -1 for whoever would win if the game ended now under the round-500 rule (queen length, then longest dragon, then total length), 0 if tied. The weights are fitted separately at 12 knots (rounds 10, 20, 50, 100, then every 50 to 450, and 490) and interpolated linearly in between. If a team has no dragons left the answer is 1 or 0, and at round 500 it is the result, not a prediction.</p>

<p>The fitted weights tell the story of a game:</p>

<figure>
  <p><img src="/assets/img/eval-weights.svg" alt="Eval weights by round" loading="lazy" /></p>
  <figcaption>The fitted weights of eval v2, by round.</figcaption>
</figure>

<p>Early on, numbers and space are what count. The queen’s weight grows from almost nothing to the largest term by the end, and the count of dragons stops mattering at all.</p>

<p>The inputs came from an analysis of about 8,500 arena games and 258 real competition games (<code class="language-plaintext highlighter-rouge">docs/METRICS.md</code>): board control and log dragons predicted best from round 50 to 350 (AUC 0.80-0.84 on real games), the longest dragon decided round 500 in about 95% of games that got there, and kills predicted nothing on their own. Deaths even went slightly <em>with</em> winning: bots that split and expand lose more dragons. The finer queen metrics added nothing on top of whether she was alive.</p>

<p>Version 1 was fitted on arena games only. Version 2 (fitted 2026-10-02 04:40Z) used 1,141 games between the competition’s top teams, 435 of our server games and 3,980 arena games, each set weighted equally. Our bots didn’t play as well as the top players, so a model fitted on our own games had to be refitted against real ones. The top-team games played differently: round 500 was decided by the queen in 25% of them, against about 4% in our arena and server games. With them in the fit, v2 gives the queen about twice v1’s weight from round 200 on.</p>

<h2 id="how-well-it-predicted">How well it predicted</h2>

<p>Out of fold, on top-team games, v2 called the winner 70% of the time at round 100, 75% at r300, 81% at r400 and 96% at r490. Version 1 had managed 70, 68, 74 and 89%. On our own server games v2 got 72, 76, 77 and 95%.</p>

<p>Workers reported the mean eval against their parent at r100, r200 and r300 next to their W-D-L, and analysts used the curves to find where losses turned: a loss whose eval stays below 0.5 from round 60 is a different problem from one that sits at 0.8 until a queen dies at round 410.</p>

<h2 id="the-evals-service">The evals service</h2>

<p><code class="language-plaintext highlighter-rouge">arena evals</code>, a separate process, kept checking it against games it hadn’t seen: finished battles between <em>other</em> teams on the competition server. Every 5 minutes it read the leaderboard, took the last six battles of each of the top 40 teams, downloaded any it hadn’t seen and computed their metrics. A fitted metric carries the timestamp of the newest game its fit saw, and the <code class="language-plaintext highlighter-rouge">/evals</code> page judges it only on later games.</p>

<p>The page showed accuracy, AUC, log loss and Brier per round against a baseline (whoever leads under the round-500 rule right now), calibration plots, and accuracy over 5-hour to all-time windows, so a change in how the top teams played would show in the short windows first. By the time the arena stopped, the snapshot held 64,090 eval games from 10,947 server battles.</p>

<h2 id="what-it-taught-us">What it taught us</h2>

<p>The biggest lesson came from evals-r13 on 10-05: an analyst took 2,382 eval games from 342 battles involving a top-10 team, plus 214 decoded replays, and compared them with 211 server games of our then-head ring-plus-9.</p>

<p>The top teams did not guard their queen better. Theirs died in 66% of games, ours in 36%. But they won 53% of the games in which their queen died, and we won 23%. What they had was army: at round 300 their total length was 120 to our 81, their board control 0.60 to our 0.51, and their longest dragon 12.7 to our 5.1. We lost 171 dragons to self-inflicted deaths before r300 against their 113, mostly 2-long split children boxed in by our own bodies.</p>

<figure>
  <p><img src="/assets/img/film-top-trophy.webp" alt="A top team on Trophy, rounds 2 to 98" loading="lazy" /></p>
  <figcaption>A top team (cyan) on Trophy, rounds 2 to 98. Its queen travels inside the swarm, and by round 98 it has 27 dragons.</figcaption>
</figure>

<p>A server analysis the same day (server-r13, 156 losses) agreed from the other side. In 69 of 75 losses where our queen died head-on, P(win) was already below 0.35 before she died. More queen safety was worth at most about 5 games: the queen deaths were a symptom of a game already lost.</p>

<p>The deep analysis of 10-07 (<code class="language-plaintext highlighter-rouge">docs/analysis-2026-10-07</code>) used the eval to assign each of 287 server losses one primary cause. 150 of them (52%) were lost in the opening: the eval stayed below 0.5 from round 60 or earlier. Board control at round 60 between 0.2 and 0.3 won 32% of the time; between 0.7 and 0.8, 90%. The same report found our queen died in 207 of 288 losses, 64 of them thrown wins where the enemy queen had died first. Both readings are true; the eval let us say which came first.</p>

<figure>
  <p><img src="/assets/img/film-devil-opening.webp" alt="An opening loss on Devil, rounds 0 to 80" loading="lazy" /></p>
  <figcaption>One of those opening losses, on Devil (ours in cyan). The enemy sent every starter to the central beds by round 20; by round 80 it had 33 dragons to our 5.</figcaption>
</figure>

<h2 id="metrics-are-not-goals">Metrics are not goals</h2>

<p>On 10-05 we reminded the orchestrator of Goodhart’s law: a metric must not become the goal. A change can move the number named in its brief without fixing the behaviour.</p>

<p>We had examples on file. evals-r13 said we lost far more split children to our own bodies, so on 10-05 two workers cut splits: ring-churn-1 refused growth splits in crowds and went 38-41; ring-longmid-1 split only at longer lengths and went 25-42. Both shrank the army. The short-lived children were paying for themselves by taking pearls and space first. The churn number was real; treating it as the target was the mistake. ring-sprint-1 (10-06) closed a gap the analyses had measured, raising free multi-step moves from 2.5% to 33% of eligible moves, and its measured server performance didn’t move: its long dragons were killed more often, and the longest at r400 was unchanged. And on 10-06 the orchestrator held back ring-qforage-1 because its early queen death rate had doubled, while it was winning. We corrected that too: the queen’s survival is evidence to weigh, never a cap by itself.</p>

<p>What changed in practice:</p>

<ul>
  <li>Briefs named the target behaviour seen in replays; metrics were evidence for it.</li>
  <li>Workers checked decoded replays of their own draft in the targeted situation, before and after, and named the game ids. “A metric moved but the replays don’t show the intended behaviour” counted as a failure.</li>
  <li>Before a promotion, an analyst rendered the draft’s games as filmstrips and looked at them. Images earned their place: one Colosseum filmstrip showed our lone queen at length 7 among pearls, contradicting a worker’s numbers-based story that she sat at length 2 with no pearls.</li>
</ul>

<p>The eval stayed on every game page until the end. It just stopped being the thing we tried to raise.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[How the arena turned every replay into per-round metrics and a P(A wins) curve, how well that curve predicted real games, and what it taught us about the top teams.]]></summary></entry><entry><title type="html">3. The arena and vast.ai workers</title><link href="https://battlecode-underthec.github.io/2026/10/11/3-arena/" rel="alternate" type="text/html" title="3. The arena and vast.ai workers" /><published>2026-10-11T00:03:00+00:00</published><updated>2026-10-11T00:03:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/3-arena</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/3-arena/"><![CDATA[<p>On 10-02 we told the orchestrator to stop running our own league and use the team arena instead: tokenz-arena, built the evening before (first commit 10-01 20:35, 129 commits in all, <a href="https://github.com/Battlecode-UnderTheC/tokenz-arena">on GitHub</a>). From then on, every verdict on every bot came from it. An import agent loaded 209 of our old local variants as drafts on the first day.</p>

<p>We built it because our teammates wanted to work on the bots too, and the local league from <a href="/2026/10/11/2-mass-codegen/">the previous post</a> lived on one laptop. That league’s simulator had also disagreed with the official judge, so the arena was built not to have one. Its design doc keeps two lessons from the league: a child against its parent is the cleanest signal, and local wins aren’t server wins.</p>

<h2 id="the-design">The design</h2>

<p>The first principle in tokenz-arena’s <a href="https://github.com/Battlecode-UnderTheC/tokenz-arena/blob/main/DESIGN.md">DESIGN.md</a> is “the official engine is the only judge”. Every game is <code class="language-plaintext highlighter-rouge">unswbc run &lt;map&gt; A B --sandbox --seed S</code> at the toolkit version the competition server runs. There are no fast simulators. The rest follows from wanting agents to use it without help:</p>

<ul>
  <li><strong>One HTTP API.</strong> Upload a bot as a zip, wait for its verdict, read its report, download replays and sources. An agent needs only <code class="language-plaintext highlighter-rouge">curl</code>. The same endpoints are exposed as MCP tools at <code class="language-plaintext highlighter-rouge">/mcp</code>, and <code class="language-plaintext highlighter-rouge">/llms.txt</code> explains the whole API on one page with live numbers. <code class="language-plaintext highlighter-rouge">docs/AGENTS.md</code> was the brief we handed to worker agents.</li>
  <li><strong>A submission is an experiment.</strong> It names its parent and carries a note: the hypothesis and what changed. <code class="language-plaintext highlighter-rouge">/ideas</code> searches the notes paired with their verdicts, so an agent can check whether its idea already lost before writing it.</li>
  <li><strong>No auth.</strong> The arena is only reachable on the team’s Tailscale network, and it asks Tailscale who is calling. Whatever runs on a teammate’s laptop submits as that teammate.</li>
</ul>

<figure>
  <p><img src="/assets/img/arena-leaderboard.webp" alt="The arena leaderboard" loading="lazy" /></p>
  <figcaption>The arena leaderboard, as the snapshot keeps it. Every submission carries a note with its hypothesis.</figcaption>
</figure>

<h2 id="how-it-fits-together">How it fits together</h2>

<figure>
  <p><img src="/assets/img/arena-components.svg" alt="The arena components" loading="lazy" /></p>
  <figcaption>The pieces of tokenz-arena. Everything ran as Docker services on one lab server, except the rented workers.</figcaption>
</figure>

<p>The arena is a Python (FastAPI) service split into two processes since 10-02. The <strong>engine</strong> is the only writer: it schedules games, runs 20 local judge slots on the lab server, hands games to remote workers, checks new submissions and refits the ratings. The <strong>web</strong> process serves the pages, the API and MCP from a read-only copy of the state that is at most a second old, and forwards anything that writes to the engine.</p>

<p>The scheduler keeps a queue in priority order: draft games first, then placement for newly promoted bots, then duels, then holdout games among the top 4, then continuous games that fill idle slots with the least-played, closest-rated pairs. Within a priority, the owner with the fewest running games goes first, so one teammate’s burst of drafts couldn’t starve the others.</p>

<p>A remote worker asks for games and gets a lease of 120 seconds, renewed by a heartbeat every 30. It sends back the result, the replay and the game’s metrics. A lease that runs out puts the game back in the queue, and a result for an expired lease, or for a game already recorded, is dropped, so no game counts twice.</p>

<p>Ratings are Bradley-Terry over ranked games among current submissions, with a draw counted as half a win, on an Elo-like scale with a reference bot fixed at 1000. At first they were refitted after every game, which made the API take 10 to 20 seconds to answer; from 10-04 they were refitted at most every 15 seconds.</p>

<p>State was plain JSON, one file per submission and per game, written with an atomic rename. That held until 10-09, when the web process was reloading every game file every two minutes and holding two copies of all of them in memory, about 7.6 GB. Games moved into SQLite that day.</p>

<h2 id="the-life-of-a-submission">The life of a submission</h2>

<figure>
  <p><img src="/assets/img/arena-submission.svg" alt="The life of a submission" loading="lazy" /></p>
  <figcaption>From upload to the leaderboard.</figcaption>
</figure>

<p><code class="language-plaintext highlighter-rouge">tokenz submit</code> zips a folder and uploads it with its parent and note. The engine checks it: a safe zip under 4 MB, no reads of environment variables, a compile with the judge’s own toolchain into the metered wasm the server uses, and one sandboxed game against a reference bot. A failure rejects it with the log.</p>

<p>A draft then plays its parent and the current top 3 on the official, tournament, server and custom suites, from both seats, unranked. The design target was a verdict in about 5 minutes. <code class="language-plaintext highlighter-rouge">beats_parent</code> is simply more wins than losses against the parent, reported with a 95% interval and the record on server maps alone. Promoting is a deliberate step by the owner or agent: it retires the bot’s previous version and starts placement, first against the parent and top 5, then against every current bot. The top 4 also play the holdout.</p>

<p>The map suites copied the league’s scheme: official maps, tournament maps rebuilt from replays, a server suite with the first two seeds the server used on each map, a weekly rotating set of generated maps, and the hidden holdout. From 10-05 the generated suites also drew on our own map generator (<code class="language-plaintext highlighter-rouge">tools/mapgen</code>), including “trick” maps built to punish one habit, such as rushing the centre.</p>

<p>The server bridge was the arena’s window on the competition server. Once a minute it imported our new server games as arena games, with the opponent as an external bot <code class="language-plaintext highlighter-rouge">server:&lt;team&gt;</code>. It also held an auto-submit gate. A candidate had to be #1 on the arena with its rating clearly above the live bot’s, win at least 55% of at least 60 games against it, be no worse on the holdout and server suites, have no errors, and stay under 70M CPU points at the 99th percentile; the gate uploaded at most once every 4 hours. On 10-02 a force-upload button was added that skips it. Both paths were used. The gate itself uploaded queen-guard-7, ring-plus-4, ring-plus-9, ring-sched-8, ring-sealed-2 and ring-shield-3; the rest went up through the button.</p>

<h2 id="remote-workers">Remote workers</h2>

<p>From 10-02 any machine with Docker could join (<code class="language-plaintext highlighter-rouge">docs/WORKERS.md</code>). The worker image, about 420 MB, held the competition’s toolkit, the C++ judge, the native metrics and Tailscale. On start it joined the team network with a key that could reach only the arena, and every call carried its own worker token. A worker on a different toolkit version was refused, so its results never mixed with the ratings.</p>

<p>A worker ran many games at once, kept a few leased games ready so no slot sat idle, downloaded each bot’s prebuilt wasm instead of compiling it (that took a game from 60 seconds to 10), and cached bots and maps by hash. An error caused by the machine, not the bot, didn’t count as a result: the game went back in the queue until it had failed on three different machines.</p>

<h2 id="the-vastai-scaler">The vast.ai scaler</h2>

<figure>
  <p><img src="/assets/img/arena-scaler.svg" alt="The vast.ai scaler loop" loading="lazy" /></p>
  <figcaption>The scaler. It rented at most one machine a minute and returned machines it no longer needed.</figcaption>
</figure>

<p>The same evening the scaler (<code class="language-plaintext highlighter-rouge">arena/scaler.py</code>, <code class="language-plaintext highlighter-rouge">docs/SCALER.md</code>) started renting workers on vast.ai. Our notes record why vast.ai: about $2.5-4.5 per 1000 thread-hours, against $15-20 on AWS Spot. Every minute it read the queue, worked out how many judge slots it would take to clear the backlog in about 5 minutes, subtracted the slots we already had, and, if over 200 games were waiting (500 before 10-05), rented one machine. It searched for verified hosts with at least 64 threads, picked the cheapest per slot, and bid 15% over the asking price for an interruptible instance. The container joined Tailscale by itself and started leasing. A machine idle for 10 minutes, outbid, or no longer needed was returned, and a worker that lost contact with the arena for 20 minutes destroyed its own instance.</p>

<p>The caps we approved were $1 an hour, $10 a day, 6 machines and 4 rentals an hour, with a prepaid balance as the last guard. We didn’t record what it spent in total. What the snapshot does record: 264 rented machines played games between 10-02 and 10-09, and they played 182,013 of the arena’s 291,668 games, 62%. The busiest played 9,292 games in about an hour.</p>

<p>Its early bugs, mostly on the morning of 10-05, were the kind an interruptible market invites: two of our instances on one host outbidding each other, the same flaky cheapest host rented and lost four times in 8 minutes, and wasmtime starting a thread pool as large as the host for every game until containers hit their task limit. Each got a rule: one instance per host, a 2-hour skip list, two threads per judge. One fix was deployed to the live arena without approval, and from then arena changes needed explicit permission.</p>

<h2 id="the-c-judge">The C++ judge</h2>

<p><code class="language-plaintext highlighter-rouge">unswbc run --sandbox</code> spends most of a game in Python glue around the wasm: turn framing, the bots’ system calls, the engine’s host calls. On 10-02 and 10-03 we ported that glue to C++ (<code class="language-plaintext highlighter-rouge">judge/</code>, <code class="language-plaintext highlighter-rouge">docs/CPPJUDGE.md</code>), about 2,200 lines, keeping everything that decides a game, the metered bot builds and the engine wasm, exactly as the official judge has them. It produces the same replay byte for byte and used 1.6x to 7x less CPU. Whenever it couldn’t be exact (a Python bot, a missing binary, a toolkit release whose ported files changed) it handed the game to the official judge, and one game in 50 was played by both as a spot check, with the official result counting. It played 225,633 of the arena’s games. All 4,535 spot checks matched.</p>

<h2 id="duels-and-ephemeral-bots">Duels and ephemeral bots</h2>

<p>From 10-04, workers could try a change before submitting it (<code class="language-plaintext highlighter-rouge">docs/DUELS.md</code>). An ephemeral bot is a zip checked like a submission but kept off the ladder, deleted after 24 hours. A duel plays any two bots on fresh random seeds (<code class="language-plaintext highlighter-rouge">random:N</code>), both seats, on any suite except the holdout. The fresh seeds keep a bot from being tuned to the ladder’s fixed ones. The result reports W-D-L with a 95% interval and whether it excludes 50%. Duel games are stored apart, so nothing official can see them.</p>

<h2 id="the-end">The end</h2>

<p>The last arena game was played on 10-09 at 05:57Z, when we stopped all workers. 10-09 was the submission day. By then it had played 291,668 games between 680 submissions, plus 629 duels with 30,656 games. Its replays came to about 196 GB zipped.</p>

<p>On 10-11 it became a static site, the <a href="https://battlecode-underthec.github.io/arena/">arena snapshot</a> (repo <a href="https://github.com/Battlecode-UnderTheC/arena">Battlecode-UnderTheC/arena</a>). We published it so that this series could point at the games it describes. The pages are the arena’s own website, with the backend ported into a service worker that reads one SQLite file through HTTP range requests. The replays aren’t stored. A game is deterministic given its map, seed and both bots, so the game page runs the official engine wasm (unswbc 1.2.9) and both bots’ metered builds in a port of the judge’s sandbox, one Web Worker per dragon, refilling 100M CPU points per turn. The toolkit’s replay viewer shows the game as it runs and says whether the result matches the arena’s record.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[tokenz-arena, the team's shared league on the official judge: how its pieces fit together, the life of a submission, the workers it rented on vast.ai, and the C++ judge.]]></summary></entry><entry><title type="html">2. Mass code generation</title><link href="https://battlecode-underthec.github.io/2026/10/11/2-mass-codegen/" rel="alternate" type="text/html" title="2. Mass code generation" /><published>2026-10-11T00:02:00+00:00</published><updated>2026-10-11T00:02:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/2-mass-codegen</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/2-mass-codegen/"><![CDATA[<p>The idea came from NYPC, whose rules prohibit calling LLMs en masse in an automated way (<a href="/2026/10/11/0-vibe-coded/">post 0</a>). Our reading of the ban was short: if a competition bothers to ban it, it probably works, and there is published work behind the approach.</p>

<p>On 09-28 the <a href="/2026/10/11/1-rl/">reinforcement learning plan</a> had just lost 79-1-0 to a scripted C++ bot written the same night. That bot became strategy-v1, our third server submission, and the starting point for everything below. We didn’t improve it by hand because we didn’t want to spend much of our own time: a loop could run without us.</p>

<h2 id="the-bet">The bet</h2>

<p>Instead of training one bot, we would have LLM agents write many bot variants, measure every one of them the same way, and breed from the winners. A human would write the shared layer (engine port, protocol parser, the agent interface) and the measuring tools. Agents would write strategies.</p>

<p>The plan is in <a href="https://github.com/Battlecode-UnderTheC/syeyoung-tokens/blob/main/DESIGN.md">DESIGN.md</a> in syeyoung-tokens. Its first principle was “the league is the only judge”: an agent never reports its own score. Its second was that nothing lives only in a terminal. Every match is a database row, every generation gets a committed report, and there are no command-line tuning knobs.</p>

<h2 id="the-variant-contract-and-the-league">The variant contract and the league</h2>

<p>A variant was a directory, <code class="language-plaintext highlighter-rouge">variants/&lt;name&gt;/</code>, holding:</p>

<ul>
  <li>the strategy as C++, written against a shared agent interface;</li>
  <li><code class="language-plaintext highlighter-rouge">variant.toml</code> with its name, 0-2 parents, an operator (fresh, mutate, crossover or tweak), an archetype and the author;</li>
  <li><code class="language-plaintext highlighter-rouge">NOTES.md</code>: the idea, what changed from the parents, and what the author expected, written before evaluation.</li>
</ul>

<p>Its identity was an annotated git tag, <code class="language-plaintext highlighter-rouge">variant/&lt;name&gt;</code>, which never moved. <code class="language-plaintext highlighter-rouge">scripts/submit.sh</code> refused an existing name and refused any change outside the variant’s own directory. The same source built two ways: a <code class="language-plaintext highlighter-rouge">.so</code> plugin loaded by our native simulator and the league, and a flat unswbc bot for the real judge.</p>

<p>The league service (<code class="language-plaintext highlighter-rouge">league/league.py</code>, 09-28) was standard-library Python with a SQLite database. Each pass it:</p>

<ol>
  <li>ingested new <code class="language-plaintext highlighter-rouge">variant/*</code> tags;</li>
  <li>ran the gates: <code class="language-plaintext highlighter-rouge">check</code> on the public maps (no crash, no avoidable wall or self death), then at least 75% wins against a random anchor, both seats;</li>
  <li>played matches six at a time, every pair on every public and rotating map from both seats;</li>
  <li>refit Bradley-Terry ratings (draws as half a win, a weak prior), on an Elo scale with anchor-random fixed at 1000.</li>
</ol>

<p>The maps came in suites. Public were the official maps. Rotating was 20 generated maps, replaced every 7 days. The holdout was 60 maps from the RL project’s map pool plus 40 generated from a secret seed range. Agents never saw the holdout or any per-map result on it, and it never fed the rating. It was there to pick champions and to catch overfitting.</p>

<p>The whole thing ran on one M1 Pro Mac with 10 cores. The native engine played about a match per second per thread.</p>

<h2 id="generations-then-a-continuous-loop">Generations, then a continuous loop</h2>

<p>Generation 1 started on 09-28 with strategy-v1 (Elo 1743), the two anchors, and six tasks. The orchestrator, a Claude Code session following <a href="https://github.com/Battlecode-UnderTheC/syeyoung-tokens/blob/main/ORCHESTRATOR.md">ORCHESTRATOR.md</a>, wrote one brief per task and launched one worker per brief in its own git worktree. Workers were Opus 5.5, plus Sonnet 5 for a pure parameter tweak. Three started together. The other three joined an hour later, when we lifted the cap of three, since workers mostly wrote code and needed little CPU.</p>

<p>The results came back that evening. counter-swarm-1 rated 1907, gather-split-trade-1 1892 and swarm-hunt-1 1873, all against strategy-v1’s 1740. Each beat strategy-v1 45-0-17 or 44-0-18.</p>

<p>Midway through generation 1, we asked for a much shorter loop, because a generation took too long. From the evening of 09-28 tasks were capped at 20 minutes, six slots ran at once, and a new task started whenever a slot freed up, with no barrier between generations. The orchestrator still numbered tasks by generation, so “g14-9” is task 9 of generation 14. By g25 on 09-29 the gst line alone had reached gather-split-trade-131. The repository took 434 commits on 09-28 and 509 on 09-29, counting the workers’ task branches (378 and 393 reached main).</p>

<p>How the agent loop itself worked, and how it changed, is <a href="/2026/10/11/5-llm-loop/">post 5</a>.</p>

<h2 id="the-gst-line">The gst line</h2>

<p>gather-split-trade-1 (“gst1”) was strategy-v1 plus a look-ahead that simulated bodies and pearl-target sharing between allies. In generation 2, gst4 added a champion that all dragons agreed on over sonar and fed in the endgame. That went 29-11 against its parent and rated about 70 Elo higher. Most of what came later in the line was small additions on that base: cap-aware trap safety (gst27), a portal fix, partial sightings and obituaries for dead champions, a centre pull on small maps, and pricing dead ends by expected loss so short dragons could farm corridors and split out (gst96).</p>

<p>Other lines ran beside it: counter-swarm, swarm-hunt, body-wall, late-surge, giant, territory, portal-farmer. They traded ideas freely. A gst fix was often ported into three other lines within the hour. gst stayed on top. What the lines actually did is in <a href="/2026/10/11/6-bots/">post 6</a>.</p>

<p>We submitted to the competition server from the league’s top. We approved gst27 as v4 on 09-28 around 20:40. From 09-29 we had standing authority to submit the best bot every four hours:</p>

<table>
  <thead>
    <tr>
      <th>server version</th>
      <th>variant</th>
      <th>submitted</th>
      <th>evidence at the time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>v4</td>
      <td>gather-split-trade-27</td>
      <td>09-28 ~20:40</td>
      <td>judge sandbox 2-1 vs strategy-v1 (v3)</td>
    </tr>
    <tr>
      <td>v5</td>
      <td>gather-split-trade-51</td>
      <td>09-29 02:54</td>
      <td>league #1, judge sandbox 10-1 vs v4</td>
    </tr>
    <tr>
      <td>v6</td>
      <td>gather-split-trade-90</td>
      <td>09-29 06:54</td>
      <td>league #1, sandbox 12-10 vs v5</td>
    </tr>
    <tr>
      <td>v7</td>
      <td>gather-split-trade-96</td>
      <td>09-29 17:57</td>
      <td>league #1, sandbox 5-3 vs v6</td>
    </tr>
    <tr>
      <td>v8</td>
      <td>gather-split-trade-131</td>
      <td>09-30 01:01</td>
      <td>sim 287-233 vs v7, judge sandbox 24-20</td>
    </tr>
  </tbody>
</table>

<p>The sandbox evidence behind each was a handful of games. The league rating carried the decision, and the last section of this post is about why that was a problem.</p>

<h2 id="the-first-server-battles">The first server battles</h2>

<p>The first server battle went 5-5 against a trading swarm: no sonar, always <code class="language-plaintext highlighter-rouge">SPLIT 2</code>, 3-5-long dragons lunging 2-4 steps at our long ones. Our dragons spent 8.7% of their moves in idle loops, 75% of those orbiting portals. The second we lost 2-8 to a stronger version of the same shape that crossed portals 1,098 times and farmed portal-only food (709 pearls to our 8 on Trauma).</p>

<figure>
  <p><img src="/assets/img/film-first-battle.webp" alt="Our champion dying head-on in the first server battle" loading="lazy" /></p>
  <figcaption>The first server battle, on Autarky, rounds 390 to 395 (ours in cyan). Our 21-long champion meets a 5-long enemy head-on, both die, and its length is left on the board as pearls.</figcaption>
</figure>

<p>That evening produced a portal fix (gst33 and its ports into the other lines) and a new kind of variant: clones of real opponents, starting with trading-swarm-1. The clones mattered because A/B tests against our own parents couldn’t see these weaknesses at all.</p>

<h2 id="what-the-league-got-wrong">What the league got wrong</h2>

<p><strong>Whose side we were on.</strong> For about a day we read server replays with the sides swapped. The replay’s <code class="language-plaintext highlighter-rouge">botA</code>/<code class="language-plaintext highlighter-rouge">botB</code> fields hold <em>our</em> submission id, and the opponent’s is blank. The “opponent” diving 1-wide columns on Devil was our own bot, in a win. On 09-29 we corrected the record: the real series against the current opponents was 7-18, not 13-7, and trading-swarm-6 was a clone of our own behaviour. g19’s premise was wrong and three of its tasks were dropped. The fix in KNOWLEDGE.md was a rule: identify our team by our own sonar words, never by the name.</p>

<p><strong>Sim versus judge.</strong> By g25 (09-29 evening) the numbers stopped agreeing. Every variant since v7 that beat it 55-60% in our simulator or the league scored 19-24 out of 44 against it in the judge’s sandbox: gst100, 101, 115, 127, 128 and 131. Even at the same seed, single games differed. Until it was fixed, verdicts moved to multi-seed sandbox duels (<code class="language-plaintext highlighter-rouge">scripts/sbx-duel.sh</code>), and a tooling task was set to make the simulator reproduce the judge.</p>

<p>The cause was found on 10-01. Apple clang on arm64 compiles with <code class="language-plaintext highlighter-rouge">-ffp-contract=on</code> by default, so our native plugins fused <code class="language-plaintext highlighter-rouge">a*b+c</code> into one FMA instruction. The judge runs bots as WebAssembly, which never fuses. Floating-point scores differed in the last bit, ties broke the other way, and games diverged. Before the fix, gst-queen-1 against gst131 matched the judge in 18 of 30 games in one set and 13 of 20 in another. After compiling everything with</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-ffp-contract=off
</code></pre></div></div>

<p>they matched 30 of 30 and 20 of 20. An earlier theory, shared mutable state in the bots, had been wrong. Every sim number for the score-based lines (gst, search, rollouts) from before the fix carried this noise. That includes the evidence behind v5 to v8, although each of those also had sandbox games.</p>

<p>The league was the only judge by design, and for those days it had been judging a slightly different game. On 10-02 we stopped running it and moved every verdict to <a href="/2026/10/11/3-arena/">the team arena</a>, which plays only the official judge. By then the local league database held 170,039 matches between 206 variants.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[How we replaced one trained bot with hundreds of LLM-written variants, a local league to rank them, and what that league got wrong.]]></summary></entry><entry><title type="html">1. RL</title><link href="https://battlecode-underthec.github.io/2026/10/11/1-rl/" rel="alternate" type="text/html" title="1. RL" /><published>2026-10-11T00:01:00+00:00</published><updated>2026-10-11T00:01:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/1-rl</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/1-rl/"><![CDATA[<p>Our first plan for the dragons game was reinforcement learning, because the NYPC winner had used it. It lived in <a href="https://github.com/Battlecode-UnderTheC/syeyoung-rl">syeyoung-rl</a> and its game submodule <a href="https://github.com/Battlecode-UnderTheC/dragons-env">dragons-env</a>, and it ran from the first commit on 2026-09-22 at 20:09 to the last training commit on 09-27. On 09-28 a scripted bot beat the policy 79-1-0, and that was the end of RL for this project.</p>

<p>The design documents state what the policy was supposed to be. The implementation spec says: “Strategies must emerge from team-outcome RL; no tactical teaching curriculum.” <code class="language-plaintext highlighter-rouge">COMPETITIVE_RL_DESIGN.md</code> lists the behaviours we hoped would emerge (harvesting circuits, body blocking, selective splitting, cheap head trades, recovering corpse pearls), and calls them “hypotheses to test, not rules to hard-code into the actor”.</p>

<h2 id="the-simulator">The simulator</h2>

<p>The rules lived only in the official wasm engine. Driving it in-process gave about 20k dragon-turns per second on one thread, which is slow for self-play. On 09-24 dragons-env was rewritten as a vectorised C++ port of the engine, checked byte for byte against the unswbc 1.0.0 wasm build. On one 14-core machine, with every thread busy, it stepped 7.7M dragon-turns per second. It could also hand a critic a summary of the true board, which no bot ever sees.</p>

<h2 id="the-model">The model</h2>

<p>The policy runs once per dragon per turn. Our C++ layer turned the protocol text into what that dragon remembered (memory is private, and a child created by a split starts with none), then packed it into fixed-size inputs:</p>

<ul>
  <li>a 17x17 window of the remembered map around the head: for each tile, when it was last seen, pearls, bed countdowns, who occupies it, the kind of each edge and where its portal leads;</li>
  <li>an 8x8 coarse grid summarising the whole remembered board: per cell, how much was seen, pearls, beds, allies, enemies, kelp and portals;</li>
  <li>up to 49 visible dragons, the last 64 sonar words received, a few status values and the previous command.</li>
</ul>

<figure>
  <p><img src="/assets/img/rl-model.svg" alt="The policy network for one decision" loading="lazy" /></p>
  <figcaption>One decision. Each input has its own encoder; the outputs are joined, fused and fed to a GRU that carries the dragon from turn to turn.</figcaption>
</figure>

<p>Each input has its own small encoder: convolutions and per-tile MLPs over the window, MLPs with mean and max pooling over sets such as the visible dragons and the inbox. The outputs are joined into 512 numbers and fused to 192. A GRU with a 192-wide state is the dragon’s memory between turns. It lives as long as the dragon does, and a split child starts from zero. The final actor had 675,798 parameters.</p>

<p>The decoder builds a command in steps, each conditioned on the last:</p>

<figure>
  <p><img src="/assets/img/rl-decoder.svg" alt="The decoder" loading="lazy" /></p>
  <figcaption>The decoder. A command is a kind, then a split size or a path of up to 8 steps, then one sonar choice per direction.</figcaption>
</figure>

<p>First it picks MOVE or SPLIT. A split scores every legal size. A move is a sequence of up to 8 direction tokens: a small GRU proposes one step at a time, scoring north, east, south, west and stop from the encoded tile in each direction, and walks its own remembered map, portals included, to know where the next step starts. Several tokens make a sprint. Last, each of the four sonar directions picks one of 257 options, silence or one of 256 words. The vocabulary was fixed, and what the words meant was left for training to work out.</p>

<p>The critic was used only in training. It saw the true board as an 8x8 grid, every living dragon with a summary of its actor state, and both teams’ lengths, and predicted the result for one team. It had 325,217 parameters.</p>

<p>This was the third design in a week. The first (09-22) gave each dragon a 42-channel memory map of the whole board, read by a multiscale ConvGRU that passed messages between the two ends of each portal. The second (09-25) used graph message passing. Both were replaced because each decision cost too much, not because they had been shown to play worse.</p>

<h2 id="training">Training</h2>

<p>Training was PPO with self-play on the four RTX 2080 Ti GPUs of a lab server, poscatgpu2.</p>

<figure>
  <p><img src="/assets/img/rl-training.svg" alt="The training pipeline" loading="lazy" /></p>
  <figcaption>The trainer. JAX only defined the model; one C++ process ran everything else.</figcaption>
</figure>

<p>JAX was used only to define the model and export it as compiled programs, one to act and others for gradients. A single C++ program, <code class="language-plaintext highlighter-rouge">dragons_train</code>, ran them through PJRT. Two collection workers per GPU each stepped hundreds of matches in our engine, batching every waiting decision into one GPU call per step. Finished 500-round matches went to a queue, were replayed to rebuild their observations, and fed the learner, which computed advantages, ran the gradient programs on all four GPUs and stepped Adam on the host. New weights went back to the workers. Half the matches were self-play, with both sides learning; the other half were against a snapshot from a league saved every 20 updates. The maps were freshly generated, 7 to 64 tiles a side, with the 11 contest maps making up a tenth of the pool.</p>

<p>Each update used at least 65,536 decisions: one epoch in four minibatches, a clip of 0.1, a stop when the policy moved more than 0.02 in KL, and Adam at 3e-4. There was no GAE. Every decision of every dragon on a team got the same final return G, minus the critic’s value at that point, plus that dragon’s own shaping rewards discounted by 0.95 per decision. In the last version, G was the result plus half the longest-dragon margin plus a quarter of the total-length margin. The shaping paid +0.05 for a pearl and +0.1 for an enemy that died ramming our body, and a death cost 0.1 plus 0.05 per segment. An earlier version paid 0.3 per ram, and the policy spent its dragons ramming.</p>

<h2 id="deploying-it">Deploying it</h2>

<p>A submitted bot gets 100M wasm CPU points and 48 MB of memory a turn, in a zip of at most 4 MB. The policy was rewritten as plain C++ with only the standard library, doing its arithmetic in half precision where the trained model did; it matched the JAX model’s commands on 1,199 of 1,200 test decisions. The 2.7 MB of weights first shipped as text, but decoding them blew the CPU budget, so they were compiled into the binary. A decision then cost about 23M points at the median. That policy, at update 28193, was our second server submission, v2.</p>

<h2 id="what-it-cost">What it cost</h2>

<p>syeyoung-rl has 150 commits across its branches. By day: 10 on 09-22, 18, 12, 43, 47, 15, and 5 on 09-28. Most of it went into making training fit on the GPUs: the 105 commits from 09-25 to 09-27 are mostly throughput and memory fixes. We never recorded how long the deployed run trained.</p>

<p>The README’s “Not done” section lists where this left us: “Any training run long enough to evaluate, ablations or promotion gates.” No RL run was ever evaluated against outside bots.</p>

<h2 id="the-scripted-bot">The scripted bot</h2>

<p>At 00:31 on 09-28, under five hours after the last reward change, a commit added a scripted C++ bot in its own <code class="language-plaintext highlighter-rouge">strategy/</code> folder. By then RL wasn’t looking promising, and we had decided on a loop of LLM agents instead; this bot was something for the loop to start from. The commit describes it as: “Explore, gather with many small dragons, feed them into leaders late, trade head-to-head when it pays.” It had a board memory (tiles, beds, kelp, portal pairs), breadth-first pathing with dead-end checks, value-per-round targeting and splitting out of traps, and it shared an agent interface with the RL policy so the two could play each other in the simulator.</p>

<p><code class="language-plaintext highlighter-rouge">strategy/EXPERIMENTS.md</code> records every run, all against the RL policy. “Pool 40” is the first 40 maps of the RL training pool, played from both sides (80 games); “official” is the 11 contest maps (22 games).</p>

<table>
  <thead>
    <tr>
      <th>Change</th>
      <th>Maps</th>
      <th>W-D-L</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>first version</td>
      <td>official</td>
      <td>10-0-12</td>
    </tr>
    <tr>
      <td>when trapped, split and walk the child out</td>
      <td>official</td>
      <td>18-0-4</td>
    </tr>
    <tr>
      <td>sweep baseline (split from length 8)</td>
      <td>pool 40</td>
      <td>69-0-11</td>
    </tr>
    <tr>
      <td>split from length 6</td>
      <td>pool 40</td>
      <td>74-0-6</td>
    </tr>
    <tr>
      <td>survivor fleeing turned off</td>
      <td>pool 40</td>
      <td>78-0-2</td>
    </tr>
    <tr>
      <td>split from length 4</td>
      <td>official</td>
      <td>22-0-0</td>
    </tr>
    <tr>
      <td>split from length 4</td>
      <td>pool 40</td>
      <td>79-1-0</td>
    </tr>
  </tbody>
</table>

<p>The first version lost on small maps. The file’s own finding on the remaining losses: they “were small crowded maps (arena, Colosseum) where the RL swarm wins the one-for-one head-to-head attrition. Splitting at 4 wins the count race there; fleeing hurts.” The 79-1-0 configuration was committed at 01:31 on 09-28, an hour after the bot’s first commit. It became strategy-v1 and was submitted as server v3.</p>

<p>By the afternoon of 09-28 the last commit in the repository was a rule that strategy configurations are committed code, “results are recorded by the league runner”. strategy-v1 became the first variant in the league described in the <a href="/2026/10/11/2-mass-codegen/">next stage</a>, and the league’s design document says in its opening section: “Nothing here depends on RL (abandoned 2026-09-28).”</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Our first plan was reinforcement learning: the model, how we trained it, and why a scripted bot ended it.]]></summary></entry><entry><title type="html">0. Overview</title><link href="https://battlecode-underthec.github.io/2026/10/11/0-vibe-coded/" rel="alternate" type="text/html" title="0. Overview" /><published>2026-10-11T00:00:00+00:00</published><updated>2026-10-11T00:00:00+00:00</updated><id>https://battlecode-underthec.github.io/2026/10/11/0-vibe-coded</id><content type="html" xml:base="https://battlecode-underthec.github.io/2026/10/11/0-vibe-coded/"><![CDATA[<div class="callout warn">
  <p><strong>Warning.</strong> The entire project behind these posts was vibe coded. LLM agents wrote almost all of the code: the bots, the simulators, the league, the arena, the scalers and the scripts. The posts are vibe worded too. An LLM drafted every one of them from our repositories and notes, and a human read and corrected each one before it went up. Where a number appears, it came from a commit, a log or a database; where we didn’t record something, we say so. Dates and times are UTC+9 unless marked Z (UTC).</p>
</div>

<h2 id="the-game">The game</h2>

<p>We are UnderTheC (syeyoung, bu-gye-jeong, equinox and chansolpark7), and we entered UNSW Battlecode 2026. The game ran on the competition server at game.battlecode.au, with the official toolkit shipped as the <code class="language-plaintext highlighter-rouge">unswbc</code> pip package.</p>

<p>Each team controls dragons, snake-like chains of segments, on a torus: walk off the right edge and you come back on the left. Boards on the contest maps were 11 to 64 tiles a side. Between tiles there can be kelp, a wall that kills any dragon that steps into it, or a portal, an edge paired with another edge somewhere else on the board that you step through and come out of still heading the same way.</p>

<figure class="pair">
  <div class="imgs"><img src="/assets/img/docs/map-info-0.webp" alt="A dragon crossing the east border" loading="lazy" /><img src="/assets/img/docs/kelp-and-portals-0.webp" alt="A dragon going through a portal into kelp" loading="lazy" /></div>
  <figcaption>Off the east edge and back on the west, then through a portal and straight into kelp. Animations from the <a href="https://game.battlecode.au/docs">unswbc docs</a>.</figcaption>
</figure>

<p>Some tiles are pearl beds. A bed grows a pearl after a random countdown; a dragon that eats one grows by a segment. A dragon that dies drops a pearl on every second segment of its body, so fights feed whoever cleans up. A dragon dies by stepping into kelp, into its own body, or into another dragon’s body. Two heads meeting kill both.</p>

<figure class="pair">
  <div class="imgs"><img src="/assets/img/docs/movement-1.webp" alt="A dragon eating a pearl" loading="lazy" /><img src="/assets/img/docs/death-0.webp" alt="A dead dragon dropping pearls" loading="lazy" /></div>
  <figcaption>A pearl adds a segment, and a dead dragon leaves a pearl on every second segment. Animations from the <a href="https://game.battlecode.au/docs">unswbc docs</a>.</figcaption>
</figure>

<p>A turn is one command. <code class="language-plaintext highlighter-rouge">MOVE</code> takes one or more steps. Since rules 1.2.3, a dragon of length L gets its first ceil(L/4) steps free and pays a segment for each step after that, so a sprint burns length. <code class="language-plaintext highlighter-rouge">SPLIT n</code> cuts the last n segments off as a new dragon, reversed, so the old tail becomes the new head. A team may have up to 64 dragons alive. Dragons act one at a time in id order within a round, and later movers see what earlier ones did.</p>

<figure class="pair">
  <div class="imgs"><img src="/assets/img/docs/movement-3.webp" alt="A dragon sprinting" loading="lazy" /><img src="/assets/img/docs/splitting-0.webp" alt="A dragon splitting" loading="lazy" /></div>
  <figcaption>A sprint (north, north and east in one turn), and a length-6 dragon splitting: the child leaves head first from the old tail. Animations from the <a href="https://game.battlecode.au/docs">unswbc docs</a>.</figcaption>
</figure>

<p>Every dragon is its own process. The judge forks one per dragon, memory is private, and a child created by a split starts as a brand-new process with no memory of its parent. Each dragon sees only a 7x7 window around its head (it can see through kelp). The only way to talk to teammates is sonar: a 64-bit word sent in a straight line, which is delivered to the first living dragon it reaches. That can be an enemy, or the sender itself. A dragon can ping once in each direction per turn. In the judge’s sandbox each turn gets a budget of 100M wasm CPU points.</p>

<figure class="pair">
  <div class="imgs"><img src="/assets/img/docs/vision-0.webp" alt="A dragon and its vision window" loading="lazy" /><img src="/assets/img/docs/sonar-1.webp" alt="Sonar intercepted by an enemy" loading="lazy" /></div>
  <figcaption>A dragon’s 7x7 vision as it moves across a portal and the border, and sonar stopping at the first body it meets: an enemy in the way hears everything after. Animations from the <a href="https://game.battlecode.au/docs">unswbc docs</a>.</figcaption>
</figure>

<p>A match lasts at most 500 rounds. A team with no dragons left loses. The rules changed on 2026-10-01 (unswbc 1.2.3, “Slay Queen”): dragons 0 and 1 became their teams’ queens, and a queen stays the queen through splits because the parent keeps its id. At round 500 the longer queen wins, and a dead queen counts as length 0. Ties fall back to the longest living dragon, then total length. Before that change the longest dragon decided round 500, and every bot we had was built for that.</p>

<p>The full rules, as the official engine implements them, are in <a href="https://github.com/Battlecode-UnderTheC/syeyoung-tokens/blob/main/docs/game.md">docs/game.md</a>.</p>

<h2 id="it-looked-like-nypc">It looked like NYPC</h2>

<p>Our first impression was that we had seen this kind of contest before. <a href="https://new.nypc.co.kr/en/">NYPC</a> is Nexon’s programming competition, and its Master Track gives “AI battle-type problems”: each team writes code that plays a given game, and the bots play other teams’ bots. Battlecode is the same shape. So we started from what we knew about NYPC.</p>

<p>Two things stood out. The NYPC winner had used reinforcement learning, so RL became <a href="/2026/10/11/1-rl/">our first plan</a>. And NYPC’s rules, which otherwise let contestants use LLMs freely, prohibit “calling LLMs en masse in an automated manner or using multiple LLMs to screen code”. If a competition bothers to ban that, it probably works. We also didn’t want to spend much of our own time on the contest, so after RL we wrote a loop that generated bots with LLM agents and let it run: <a href="/2026/10/11/2-mass-codegen/">mass code generation</a>.</p>

<h2 id="the-series">The series</h2>

<ol>
  <li>This post.</li>
  <li><a href="/2026/10/11/1-rl/">RL</a>: the first plan, reinforcement learning, and why a scripted bot ended it.</li>
  <li><a href="/2026/10/11/2-mass-codegen/">Mass code generation</a>: generating many bot variants with LLM agents and selecting, instead of training one bot.</li>
  <li><a href="/2026/10/11/3-arena/">The arena and vast.ai workers</a>: tokenz-arena, our team arena, and the machines that ran it.</li>
  <li><a href="/2026/10/11/4-metrics/">Metrics engine and probability analysis</a>: reading a game from its replay before it ends.</li>
  <li><a href="/2026/10/11/5-llm-loop/">The LLM loop up close</a>: orchestrator, strategists, analysts and workers, and the rules we added as they went wrong.</li>
  <li><a href="/2026/10/11/6-bots/">What the factory made</a>: the bot lines and which ideas moved the server rating.</li>
  <li><a href="/2026/10/11/7-results/">Postmortem and results</a>: the ladder, the sprint tournament, and what we’d do differently.</li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[A warning, the dragons game, why it reminded us of NYPC, and a map of this postmortem series.]]></summary></entry></feed>