Skip to content

Commit fc25764

Browse files
authored
Add Ants arena (fog-of-war swarm RTS) (#119)
* Add Ants arena (fog-of-war swarm RTS) Thin arena wrapper cloning CodeClash-ai/Ants, replay renderer (spectator view of ants/hills/food/water, per-sim winner via peek_winner), registration, and test config. * Ants replay: clearer anthill glyph (mound + entrance + owner ring) * Fix Ants PR CI: ruff import order + format, and single-player commit_label default - Sort AntsArena import (ruff isort) and reformat replay.py (ruff format) - single_player: default commit_label to '' (fixes test_single_player_battlesnake KeyError)
1 parent 3fc2249 commit fc25764

7 files changed

Lines changed: 309 additions & 0 deletions

File tree

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from codeclash.arenas.ants.ants import AntsArena
12
from codeclash.arenas.arena import CodeArena
23
from codeclash.arenas.battlecode23.battlecode23 import BattleCode23Arena
34
from codeclash.arenas.battlecode24.battlecode24 import BattleCode24Arena
@@ -22,6 +23,7 @@
2223
from codeclash.arenas.scml.scml import SCMLOneShotArena
2324

2425
ARENAS = [
26+
AntsArena,
2527
BattleCode23Arena,
2628
BattleCode24Arena,
2729
BattleCode25Arena,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM ubuntu:22.04
2+
3+
ENV DEBIAN_FRONTEND=noninteractive
4+
5+
# Install Python 3.10, pip, and prerequisites
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends \
8+
curl ca-certificates python3.10 python3.10-venv \
9+
python3-pip python-is-python3 wget git build-essential jq curl locales \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
# Clone Ants game repository
13+
RUN git clone https://github.com/CodeClash-ai/Ants.git /workspace \
14+
&& cd /workspace \
15+
&& git remote set-url origin https://github.com/CodeClash-ai/Ants.git
16+
WORKDIR /workspace
17+
18+
# No additional dependencies needed - engine uses only the standard library

codeclash/arenas/ants/__init__.py

Whitespace-only changes.

codeclash/arenas/ants/ants.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import re
2+
3+
from codeclash.agents.player import Player
4+
from codeclash.arenas.arena import CodeArena, RoundStats
5+
from codeclash.constants import RESULT_TIE
6+
from codeclash.utils.environment import assert_zero_exit_code
7+
8+
ANTS_LOG = "result.log"
9+
10+
11+
class AntsArena(CodeArena):
12+
name: str = "Ants"
13+
submission: str = "main.py"
14+
description: str = """Your bot (`main.py`) commands a swarm of ants in Ants, a fog-of-war RTS on a toroidal
15+
(wrap-around) grid, in the spirit of the 2010 Ants AI Challenge. Every turn all ants move one cell at
16+
once. You only see the map within your ants' view radius (fog of war). Combat is focus-fire: an ant dies
17+
unless it has strictly fewer enemies within attack range than every enemy attacking it (so gang up to
18+
win fights). Food within spawn radius of exactly one player's ants is gathered and spawns a new ant on a
19+
hill. Move an ant onto an enemy hill to raze it -- razing enemy hills is the objective. Most hills razed
20+
(tiebreak: ants alive) wins.
21+
22+
Your bot must implement:
23+
def do_turn(obs: dict) -> list
24+
25+
Return a list of moves, each [row, col, dir] with dir in "N","S","E","W" (N = row-1); the ant at
26+
(row, col) steps that way. Un-ordered ants stay; moving into water is ignored; two ants onto one cell
27+
both die. do_turn runs in one long-lived process, so you may keep state (a remembered map) in globals.
28+
29+
`obs` is your fog-limited view: obs["turn"]/["max_turns"], obs["rows"]/["cols"] (toroidal),
30+
obs["viewradius2"]/["attackradius2"]/["spawnradius2"], obs["you"], obs["my_ants"], obs["my_hills"],
31+
and (only where visible) obs["enemy_ants"], obs["enemy_hills"], obs["food"], obs["water"].
32+
Distances are toroidal: dr=min(|dr|,rows-|dr|), dc likewise, compare dr*dr+dc*dc to the radius2 values.
33+
"""
34+
35+
def __init__(self, config, **kwargs):
36+
super().__init__(config, **kwargs)
37+
assert len(config["players"]) >= 2, "Ants needs at least two players"
38+
39+
def execute_round(self, agents: list[Player]) -> None:
40+
args = [f"/{agent.name}/{self.submission}" for agent in agents]
41+
cmd = (
42+
f"python engine.py {' '.join(args)} -r {self.game_config['sims_per_round']} "
43+
f"-o {self.log_env} > {self.log_env / ANTS_LOG};"
44+
)
45+
self.logger.info(f"Running game: {cmd}")
46+
assert_zero_exit_code(self.environment.execute(cmd))
47+
48+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
49+
with open(self.log_round(round_num) / ANTS_LOG) as f:
50+
round_log = f.read()
51+
lines = round_log.split("FINAL_RESULTS")[-1].splitlines()
52+
53+
# Engine prints: "Bot_<i>: <wins> games won (<name>)". Score = games won.
54+
scores: dict[str, float] = {}
55+
for line in lines:
56+
match = re.search(r"Bot_(\d+):\s+(\d+)\s+games\s+won", line)
57+
if match:
58+
bot_id = int(match.group(1))
59+
wins = int(match.group(2))
60+
if 1 <= bot_id <= len(agents):
61+
scores[agents[bot_id - 1].name] = wins
62+
63+
draw_match = re.search(r"Draws:\s+(\d+)", round_log)
64+
if draw_match and int(draw_match.group(1)) > 0:
65+
scores[RESULT_TIE] = int(draw_match.group(1))
66+
67+
if scores:
68+
real = {k: v for k, v in scores.items() if k != RESULT_TIE}
69+
max_score = max(real.values()) if real else 0
70+
winners = [k for k, v in real.items() if v == max_score]
71+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
72+
else:
73+
stats.winner = "unknown"
74+
75+
stats.scores = scores
76+
for player, score in scores.items():
77+
if player != RESULT_TIE:
78+
stats.player_stats[player].score = score
79+
80+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
81+
if self.submission not in agent.environment.execute("ls")["output"]:
82+
return False, f"No {self.submission} file found in the root directory"
83+
84+
bot_content = agent.environment.execute(f"cat {self.submission}")["output"]
85+
if "def do_turn(" not in bot_content:
86+
return (
87+
False,
88+
f"{self.submission} must define a do_turn(obs) function. "
89+
"See the game description for the required signature.",
90+
)
91+
92+
return True, None

codeclash/arenas/ants/replay.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Ants replay renderer.
2+
3+
Parses a per-game ``sim_*.json`` written by the engine and renders the toroidal grid
4+
from the spectator's view (no fog): water, food, hills, and every player's ants.
5+
6+
The JSON format (see engine.py ``write_replay``)::
7+
8+
{"rows":32, "cols":32, "num_players":2, "water":[[r,c], ...],
9+
"names":["p1","p2"], "winner": 0 | null,
10+
"frames":[{"t":0, "ants":[[r,c,owner], ...], "hills":[[r,c,owner], ...],
11+
"food":[[r,c], ...]}, ...]}
12+
13+
Water is static (recorded once). Each frame lists the live ants, living hills, and
14+
food. ``winner`` is the winning player id, or ``null`` for a draw. Grid cells are
15+
``[row, col]`` (row = y, col = x); the board wraps, but the renderer just draws it flat.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
22+
from codeclash.replay.base import ReplayData, ReplayRenderer
23+
24+
DRAW_JS = """
25+
const ARENA = (function(){
26+
const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594'];
27+
const BG = '#0d1117', WATER = '#15304a', FOOD = '#e6edf3', LINE = 'rgba(255,255,255,0.04)';
28+
let COLS, ROWS, CELL, WATERSET;
29+
function col(i){ return PAL[i % PAL.length]; }
30+
function setup(cv, G){
31+
COLS = G.w; ROWS = G.h;
32+
CELL = Math.max(8, Math.min(22, Math.floor(640 / COLS)));
33+
cv.width = COLS * CELL; cv.height = ROWS * CELL;
34+
WATERSET = G.water || [];
35+
}
36+
function draw(ctx, cv, G, i){
37+
const f = G.frames[i];
38+
ctx.fillStyle = BG; ctx.fillRect(0, 0, cv.width, cv.height);
39+
// water
40+
ctx.fillStyle = WATER;
41+
WATERSET.forEach(w => ctx.fillRect(w[1]*CELL, w[0]*CELL, CELL, CELL));
42+
// grid lines
43+
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
44+
for(let x=0;x<=COLS;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); }
45+
for(let y=0;y<=ROWS;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); }
46+
// hills — an anthill glyph: a soft owner-tinted base, a mound with an entrance
47+
// hole, and a bold owner-colored ring that stays visible even with an ant on top.
48+
(f.hills||[]).forEach(h => {
49+
const cc = col(h[2]);
50+
const cx = h[1]*CELL + CELL/2, cy = h[0]*CELL + CELL/2, R = CELL*0.55;
51+
// soft glow base
52+
ctx.globalAlpha = 0.22; ctx.fillStyle = cc;
53+
ctx.beginPath(); ctx.arc(cx, cy, R, 0, 7); ctx.fill(); ctx.globalAlpha = 1;
54+
// mound (dome) with a white rim for contrast
55+
ctx.fillStyle = cc;
56+
ctx.beginPath();
57+
ctx.moveTo(cx - R*0.82, cy + R*0.52);
58+
ctx.quadraticCurveTo(cx, cy - R*0.92, cx + R*0.82, cy + R*0.52);
59+
ctx.closePath(); ctx.fill();
60+
ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 1.5; ctx.stroke();
61+
// entrance hole
62+
ctx.fillStyle = '#0d1117';
63+
ctx.beginPath(); ctx.ellipse(cx, cy + R*0.14, R*0.24, R*0.3, 0, 0, 7); ctx.fill();
64+
// framing ring (marks the cell as a hill regardless of an ant sitting on it)
65+
ctx.strokeStyle = cc; ctx.lineWidth = 2.5;
66+
ctx.beginPath(); ctx.arc(cx, cy, CELL*0.5 - 1, 0, 7); ctx.stroke();
67+
});
68+
// food (small white diamonds)
69+
ctx.fillStyle = FOOD;
70+
(f.food||[]).forEach(fd => {
71+
const cx = fd[1]*CELL + CELL/2, cy = fd[0]*CELL + CELL/2, s = Math.max(2, CELL*0.22);
72+
ctx.beginPath(); ctx.moveTo(cx, cy-s); ctx.lineTo(cx+s, cy); ctx.lineTo(cx, cy+s); ctx.lineTo(cx-s, cy); ctx.closePath(); ctx.fill();
73+
});
74+
// ants (filled rounded cells in the owner's color)
75+
(f.ants||[]).forEach(a => {
76+
ctx.fillStyle = col(a[2]);
77+
const x = a[1]*CELL, y = a[0]*CELL, p = Math.max(1, CELL*0.12);
78+
ctx.fillRect(x+p, y+p, CELL-2*p, CELL-2*p);
79+
});
80+
}
81+
function side(G, i){
82+
const f = G.frames[i], NM = G.names || [], n = G.num_players || NM.length;
83+
const done = i === G.frames.length - 1;
84+
const ants = {}, hills = {};
85+
(f.ants||[]).forEach(a => ants[a[2]] = (ants[a[2]]||0) + 1);
86+
(f.hills||[]).forEach(h => hills[h[2]] = (hills[h[2]]||0) + 1);
87+
let rows = '';
88+
for(let p=0;p<n;p++){
89+
const dead = (ants[p]||0) === 0 && (hills[p]||0) === 0;
90+
rows += `<div class="team ${dead?'tdead':''}"><div class="tname">`
91+
+ `<span class="sw" style="background:${col(p)}"></span>${NM[p] || ('player'+(p+1))}</div>`
92+
+ `<div class="stat"><span>ants</span><b>${ants[p]||0}</b></div>`
93+
+ `<div class="stat"><span>hills</span><b>${hills[p]||0}</b></div></div>`;
94+
}
95+
const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : '';
96+
return rows
97+
+ `<div class="stat"><span>turn</span><b>${f.turn} / ${G.max_turns||''}</b></div>`
98+
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
99+
}
100+
return {setup, draw, side};
101+
})();
102+
"""
103+
104+
105+
class AntsReplayer(ReplayRenderer):
106+
arena = "Ants"
107+
sim_glob = "sim_*.json"
108+
DRAW_JS = DRAW_JS
109+
110+
def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
111+
log = json.loads(raw.decode(errors="replace"))
112+
rows = log.get("rows", 32)
113+
cols = log.get("cols", 32)
114+
n = log.get("num_players", 2)
115+
116+
names = list(log.get("names", []))
117+
if players:
118+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
119+
while len(names) < n:
120+
names.append(f"player{len(names) + 1}")
121+
122+
frames = [
123+
{
124+
"turn": fr.get("t", idx),
125+
"ants": fr.get("ants", []),
126+
"hills": fr.get("hills", []),
127+
"food": fr.get("food", []),
128+
}
129+
for idx, fr in enumerate(log.get("frames", []))
130+
]
131+
132+
win = log.get("winner")
133+
draw = win is None
134+
winner = None if draw else names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
135+
136+
return ReplayData(
137+
w=cols,
138+
h=rows,
139+
frames=frames,
140+
winner=winner,
141+
draw=draw,
142+
extra={
143+
"names": names,
144+
"num_players": n,
145+
"max_turns": log.get("max_turns", 500),
146+
"water": log.get("water", []),
147+
},
148+
)
149+
150+
def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None:
151+
log = json.loads(raw.decode(errors="replace"))
152+
win = log.get("winner")
153+
if win is None:
154+
return (None, True)
155+
names = list(log.get("names", []))
156+
if players:
157+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
158+
name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
159+
return (name, False)

codeclash/replay/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ def get_replayer(arena: str) -> ReplayRenderer | None:
6565
from codeclash.arenas.lightcycles.replay import LightCyclesReplayer
6666

6767
return LightCyclesReplayer()
68+
if arena == "Ants":
69+
from codeclash.arenas.ants.replay import AntsReplayer
70+
71+
return AntsReplayer()
6872
if arena == "Halite":
6973
from codeclash.arenas.halite.replay import HaliteReplayer
7074

configs/test/ants.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
tournament:
2+
rounds: 3
3+
game:
4+
name: Ants
5+
sims_per_round: 6
6+
players:
7+
- agent: dummy
8+
name: p1
9+
- agent: dummy
10+
name: p2
11+
prompts:
12+
game_description: |
13+
You are a software developer ({{player_id}}) competing in a coding game called Ants.
14+
Your bot (`main.py`) commands a swarm of ants in a fog-of-war RTS on a toroidal (wrap-around) grid,
15+
in the spirit of the 2010 Ants AI Challenge. Every turn all ants move one cell at once. You only see
16+
the map within your ants' view radius. Combat is focus-fire: an ant dies unless it has strictly fewer
17+
enemies within attack range than every enemy attacking it (gang up to win fights). Food within spawn
18+
radius of exactly one player's ants is gathered and spawns a new ant on a hill. Move an ant onto an
19+
enemy hill to raze it -- razing enemy hills is the objective. Most hills razed (tiebreak: ants alive)
20+
wins.
21+
22+
Your bot must implement `do_turn(obs) -> list`, returning moves [row, col, dir] with dir in
23+
"N","S","E","W". Un-ordered ants stay; moving into water is ignored; two ants onto one cell both die.
24+
do_turn runs in one long-lived process, so you may remember the map in module globals. `obs` is your
25+
fog-limited view: obs["turn"]/["max_turns"], obs["rows"]/["cols"] (toroidal),
26+
obs["viewradius2"]/["attackradius2"]/["spawnradius2"], obs["you"], obs["my_ants"], obs["my_hills"],
27+
and (only where visible) obs["enemy_ants"], obs["enemy_hills"], obs["food"], obs["water"].
28+
29+
The game is played in {{rounds}} rounds. For every round, you (and your competitor) edit program
30+
code that controls your bot. This is round {{round}}.
31+
After you and your competitor finish editing your codebases, the game is run automatically.
32+
33+
Your task: improve the bot in `main.py`, located in {{working_dir}}.
34+
{{working_dir}} is your codebase, which contains both your bot and supporting assets.

0 commit comments

Comments
 (0)