Skip to content

Commit 37b5a5d

Browse files
committed
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.
1 parent 090caf0 commit 37b5a5d

7 files changed

Lines changed: 288 additions & 0 deletions

File tree

codeclash/arenas/__init__.py

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

2324
ARENAS = [
25+
AntsArena,
2426
BattleCode23Arena,
2527
BattleCode24Arena,
2628
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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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 (large bordered square in the owner's color)
47+
(f.hills||[]).forEach(h => {
48+
const x = h[1]*CELL, y = h[0]*CELL;
49+
ctx.fillStyle = col(h[2]); ctx.globalAlpha = 0.35; ctx.fillRect(x, y, CELL, CELL); ctx.globalAlpha = 1;
50+
ctx.strokeStyle = col(h[2]); ctx.lineWidth = 2; ctx.strokeRect(x+1.5, y+1.5, CELL-3, CELL-3);
51+
});
52+
// food (small white diamonds)
53+
ctx.fillStyle = FOOD;
54+
(f.food||[]).forEach(fd => {
55+
const cx = fd[1]*CELL + CELL/2, cy = fd[0]*CELL + CELL/2, s = Math.max(2, CELL*0.22);
56+
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();
57+
});
58+
// ants (filled rounded cells in the owner's color)
59+
(f.ants||[]).forEach(a => {
60+
ctx.fillStyle = col(a[2]);
61+
const x = a[1]*CELL, y = a[0]*CELL, p = Math.max(1, CELL*0.12);
62+
ctx.fillRect(x+p, y+p, CELL-2*p, CELL-2*p);
63+
});
64+
}
65+
function side(G, i){
66+
const f = G.frames[i], NM = G.names || [], n = G.num_players || NM.length;
67+
const done = i === G.frames.length - 1;
68+
const ants = {}, hills = {};
69+
(f.ants||[]).forEach(a => ants[a[2]] = (ants[a[2]]||0) + 1);
70+
(f.hills||[]).forEach(h => hills[h[2]] = (hills[h[2]]||0) + 1);
71+
let rows = '';
72+
for(let p=0;p<n;p++){
73+
const dead = (ants[p]||0) === 0 && (hills[p]||0) === 0;
74+
rows += `<div class="team ${dead?'tdead':''}"><div class="tname">`
75+
+ `<span class="sw" style="background:${col(p)}"></span>${NM[p] || ('player'+(p+1))}</div>`
76+
+ `<div class="stat"><span>ants</span><b>${ants[p]||0}</b></div>`
77+
+ `<div class="stat"><span>hills</span><b>${hills[p]||0}</b></div></div>`;
78+
}
79+
const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : '';
80+
return rows
81+
+ `<div class="stat"><span>turn</span><b>${f.turn} / ${G.max_turns||''}</b></div>`
82+
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
83+
}
84+
return {setup, draw, side};
85+
})();
86+
"""
87+
88+
89+
class AntsReplayer(ReplayRenderer):
90+
arena = "Ants"
91+
sim_glob = "sim_*.json"
92+
DRAW_JS = DRAW_JS
93+
94+
def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
95+
log = json.loads(raw.decode(errors="replace"))
96+
rows = log.get("rows", 32)
97+
cols = log.get("cols", 32)
98+
n = log.get("num_players", 2)
99+
100+
names = list(log.get("names", []))
101+
if players:
102+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
103+
while len(names) < n:
104+
names.append(f"player{len(names) + 1}")
105+
106+
frames = [
107+
{"turn": fr.get("t", idx), "ants": fr.get("ants", []), "hills": fr.get("hills", []), "food": fr.get("food", [])}
108+
for idx, fr in enumerate(log.get("frames", []))
109+
]
110+
111+
win = log.get("winner")
112+
draw = win is None
113+
winner = None if draw else names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
114+
115+
return ReplayData(
116+
w=cols,
117+
h=rows,
118+
frames=frames,
119+
winner=winner,
120+
draw=draw,
121+
extra={
122+
"names": names,
123+
"num_players": n,
124+
"max_turns": log.get("max_turns", 500),
125+
"water": log.get("water", []),
126+
},
127+
)
128+
129+
def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None:
130+
log = json.loads(raw.decode(errors="replace"))
131+
win = log.get("winner")
132+
if win is None:
133+
return (None, True)
134+
names = list(log.get("names", []))
135+
if players:
136+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
137+
name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
138+
return (name, False)

codeclash/replay/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ def get_replayer(arena: str) -> ReplayRenderer | None:
6161
from codeclash.arenas.paintvolley.replay import PaintVolleyReplayer
6262

6363
return PaintVolleyReplayer()
64+
if arena == "Ants":
65+
from codeclash.arenas.ants.replay import AntsReplayer
66+
67+
return AntsReplayer()
6468
if arena == "Halite":
6569
from codeclash.arenas.halite.replay import HaliteReplayer
6670

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)