Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codeclash/arenas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from codeclash.arenas.ants.ants import AntsArena
from codeclash.arenas.arena import CodeArena
from codeclash.arenas.battlecode23.battlecode23 import BattleCode23Arena
from codeclash.arenas.battlecode24.battlecode24 import BattleCode24Arena
Expand All @@ -22,6 +23,7 @@
from codeclash.arenas.scml.scml import SCMLOneShotArena

ARENAS = [
AntsArena,
BattleCode23Arena,
BattleCode24Arena,
BattleCode25Arena,
Expand Down
18 changes: 18 additions & 0 deletions codeclash/arenas/ants/Ants.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM ubuntu:22.04

ENV DEBIAN_FRONTEND=noninteractive

# Install Python 3.10, pip, and prerequisites
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl ca-certificates python3.10 python3.10-venv \
python3-pip python-is-python3 wget git build-essential jq curl locales \
&& rm -rf /var/lib/apt/lists/*

# Clone Ants game repository
RUN git clone https://github.com/CodeClash-ai/Ants.git /workspace \
&& cd /workspace \
&& git remote set-url origin https://github.com/CodeClash-ai/Ants.git
WORKDIR /workspace

# No additional dependencies needed - engine uses only the standard library
Empty file.
92 changes: 92 additions & 0 deletions codeclash/arenas/ants/ants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import re

from codeclash.agents.player import Player
from codeclash.arenas.arena import CodeArena, RoundStats
from codeclash.constants import RESULT_TIE
from codeclash.utils.environment import assert_zero_exit_code

ANTS_LOG = "result.log"


class AntsArena(CodeArena):
name: str = "Ants"
submission: str = "main.py"
description: str = """Your bot (`main.py`) commands a swarm of ants in Ants, a fog-of-war RTS on a toroidal
(wrap-around) grid, in the spirit of the 2010 Ants AI Challenge. Every turn all ants move one cell at
once. You only see the map within your ants' view radius (fog of war). Combat is focus-fire: an ant dies
unless it has strictly fewer enemies within attack range than every enemy attacking it (so gang up to
win fights). Food within spawn radius of exactly one player's ants is gathered and spawns a new ant on a
hill. Move an ant onto an enemy hill to raze it -- razing enemy hills is the objective. Most hills razed
(tiebreak: ants alive) wins.

Your bot must implement:
def do_turn(obs: dict) -> list

Return a list of moves, each [row, col, dir] with dir in "N","S","E","W" (N = row-1); the ant at
(row, col) steps that way. Un-ordered ants stay; moving into water is ignored; two ants onto one cell
both die. do_turn runs in one long-lived process, so you may keep state (a remembered map) in globals.

`obs` is your fog-limited view: obs["turn"]/["max_turns"], obs["rows"]/["cols"] (toroidal),
obs["viewradius2"]/["attackradius2"]/["spawnradius2"], obs["you"], obs["my_ants"], obs["my_hills"],
and (only where visible) obs["enemy_ants"], obs["enemy_hills"], obs["food"], obs["water"].
Distances are toroidal: dr=min(|dr|,rows-|dr|), dc likewise, compare dr*dr+dc*dc to the radius2 values.
"""

def __init__(self, config, **kwargs):
super().__init__(config, **kwargs)
assert len(config["players"]) >= 2, "Ants needs at least two players"

def execute_round(self, agents: list[Player]) -> None:
args = [f"/{agent.name}/{self.submission}" for agent in agents]
cmd = (
f"python engine.py {' '.join(args)} -r {self.game_config['sims_per_round']} "
f"-o {self.log_env} > {self.log_env / ANTS_LOG};"
)
self.logger.info(f"Running game: {cmd}")
assert_zero_exit_code(self.environment.execute(cmd))

def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
with open(self.log_round(round_num) / ANTS_LOG) as f:
round_log = f.read()
lines = round_log.split("FINAL_RESULTS")[-1].splitlines()

# Engine prints: "Bot_<i>: <wins> games won (<name>)". Score = games won.
scores: dict[str, float] = {}
for line in lines:
match = re.search(r"Bot_(\d+):\s+(\d+)\s+games\s+won", line)
if match:
bot_id = int(match.group(1))
wins = int(match.group(2))
if 1 <= bot_id <= len(agents):
scores[agents[bot_id - 1].name] = wins

draw_match = re.search(r"Draws:\s+(\d+)", round_log)
if draw_match and int(draw_match.group(1)) > 0:
scores[RESULT_TIE] = int(draw_match.group(1))

if scores:
real = {k: v for k, v in scores.items() if k != RESULT_TIE}
max_score = max(real.values()) if real else 0
winners = [k for k, v in real.items() if v == max_score]
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
else:
stats.winner = "unknown"

stats.scores = scores
for player, score in scores.items():
if player != RESULT_TIE:
stats.player_stats[player].score = score

def validate_code(self, agent: Player) -> tuple[bool, str | None]:
if self.submission not in agent.environment.execute("ls")["output"]:
return False, f"No {self.submission} file found in the root directory"

bot_content = agent.environment.execute(f"cat {self.submission}")["output"]
if "def do_turn(" not in bot_content:
return (
False,
f"{self.submission} must define a do_turn(obs) function. "
"See the game description for the required signature.",
)

return True, None
159 changes: 159 additions & 0 deletions codeclash/arenas/ants/replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Ants replay renderer.

Parses a per-game ``sim_*.json`` written by the engine and renders the toroidal grid
from the spectator's view (no fog): water, food, hills, and every player's ants.

The JSON format (see engine.py ``write_replay``)::

{"rows":32, "cols":32, "num_players":2, "water":[[r,c], ...],
"names":["p1","p2"], "winner": 0 | null,
"frames":[{"t":0, "ants":[[r,c,owner], ...], "hills":[[r,c,owner], ...],
"food":[[r,c], ...]}, ...]}

Water is static (recorded once). Each frame lists the live ants, living hills, and
food. ``winner`` is the winning player id, or ``null`` for a draw. Grid cells are
``[row, col]`` (row = y, col = x); the board wraps, but the renderer just draws it flat.
"""

from __future__ import annotations

import json

from codeclash.replay.base import ReplayData, ReplayRenderer

DRAW_JS = """
const ARENA = (function(){
const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594'];
const BG = '#0d1117', WATER = '#15304a', FOOD = '#e6edf3', LINE = 'rgba(255,255,255,0.04)';
let COLS, ROWS, CELL, WATERSET;
function col(i){ return PAL[i % PAL.length]; }
function setup(cv, G){
COLS = G.w; ROWS = G.h;
CELL = Math.max(8, Math.min(22, Math.floor(640 / COLS)));
cv.width = COLS * CELL; cv.height = ROWS * CELL;
WATERSET = G.water || [];
}
function draw(ctx, cv, G, i){
const f = G.frames[i];
ctx.fillStyle = BG; ctx.fillRect(0, 0, cv.width, cv.height);
// water
ctx.fillStyle = WATER;
WATERSET.forEach(w => ctx.fillRect(w[1]*CELL, w[0]*CELL, CELL, CELL));
// grid lines
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
for(let x=0;x<=COLS;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); }
for(let y=0;y<=ROWS;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); }
// hills — an anthill glyph: a soft owner-tinted base, a mound with an entrance
// hole, and a bold owner-colored ring that stays visible even with an ant on top.
(f.hills||[]).forEach(h => {
const cc = col(h[2]);
const cx = h[1]*CELL + CELL/2, cy = h[0]*CELL + CELL/2, R = CELL*0.55;
// soft glow base
ctx.globalAlpha = 0.22; ctx.fillStyle = cc;
ctx.beginPath(); ctx.arc(cx, cy, R, 0, 7); ctx.fill(); ctx.globalAlpha = 1;
// mound (dome) with a white rim for contrast
ctx.fillStyle = cc;
ctx.beginPath();
ctx.moveTo(cx - R*0.82, cy + R*0.52);
ctx.quadraticCurveTo(cx, cy - R*0.92, cx + R*0.82, cy + R*0.52);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 1.5; ctx.stroke();
// entrance hole
ctx.fillStyle = '#0d1117';
ctx.beginPath(); ctx.ellipse(cx, cy + R*0.14, R*0.24, R*0.3, 0, 0, 7); ctx.fill();
// framing ring (marks the cell as a hill regardless of an ant sitting on it)
ctx.strokeStyle = cc; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(cx, cy, CELL*0.5 - 1, 0, 7); ctx.stroke();
});
// food (small white diamonds)
ctx.fillStyle = FOOD;
(f.food||[]).forEach(fd => {
const cx = fd[1]*CELL + CELL/2, cy = fd[0]*CELL + CELL/2, s = Math.max(2, CELL*0.22);
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();
});
// ants (filled rounded cells in the owner's color)
(f.ants||[]).forEach(a => {
ctx.fillStyle = col(a[2]);
const x = a[1]*CELL, y = a[0]*CELL, p = Math.max(1, CELL*0.12);
ctx.fillRect(x+p, y+p, CELL-2*p, CELL-2*p);
});
}
function side(G, i){
const f = G.frames[i], NM = G.names || [], n = G.num_players || NM.length;
const done = i === G.frames.length - 1;
const ants = {}, hills = {};
(f.ants||[]).forEach(a => ants[a[2]] = (ants[a[2]]||0) + 1);
(f.hills||[]).forEach(h => hills[h[2]] = (hills[h[2]]||0) + 1);
let rows = '';
for(let p=0;p<n;p++){
const dead = (ants[p]||0) === 0 && (hills[p]||0) === 0;
rows += `<div class="team ${dead?'tdead':''}"><div class="tname">`
+ `<span class="sw" style="background:${col(p)}"></span>${NM[p] || ('player'+(p+1))}</div>`
+ `<div class="stat"><span>ants</span><b>${ants[p]||0}</b></div>`
+ `<div class="stat"><span>hills</span><b>${hills[p]||0}</b></div></div>`;
}
const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : '';
return rows
+ `<div class="stat"><span>turn</span><b>${f.turn} / ${G.max_turns||''}</b></div>`
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
}
return {setup, draw, side};
})();
"""


class AntsReplayer(ReplayRenderer):
arena = "Ants"
sim_glob = "sim_*.json"
DRAW_JS = DRAW_JS

def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
log = json.loads(raw.decode(errors="replace"))
rows = log.get("rows", 32)
cols = log.get("cols", 32)
n = log.get("num_players", 2)

names = list(log.get("names", []))
if players:
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
while len(names) < n:
names.append(f"player{len(names) + 1}")

frames = [
{
"turn": fr.get("t", idx),
"ants": fr.get("ants", []),
"hills": fr.get("hills", []),
"food": fr.get("food", []),
}
for idx, fr in enumerate(log.get("frames", []))
]

win = log.get("winner")
draw = win is None
winner = None if draw else names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)

return ReplayData(
w=cols,
h=rows,
frames=frames,
winner=winner,
draw=draw,
extra={
"names": names,
"num_players": n,
"max_turns": log.get("max_turns", 500),
"water": log.get("water", []),
},
)

def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None:
log = json.loads(raw.decode(errors="replace"))
win = log.get("winner")
if win is None:
return (None, True)
names = list(log.get("names", []))
if players:
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
return (name, False)
4 changes: 4 additions & 0 deletions codeclash/replay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def get_replayer(arena: str) -> ReplayRenderer | None:
from codeclash.arenas.lightcycles.replay import LightCyclesReplayer

return LightCyclesReplayer()
if arena == "Ants":
from codeclash.arenas.ants.replay import AntsReplayer

return AntsReplayer()
if arena == "Halite":
from codeclash.arenas.halite.replay import HaliteReplayer

Expand Down
34 changes: 34 additions & 0 deletions configs/test/ants.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
tournament:
rounds: 3
game:
name: Ants
sims_per_round: 6
players:
- agent: dummy
name: p1
- agent: dummy
name: p2
prompts:
game_description: |
You are a software developer ({{player_id}}) competing in a coding game called Ants.
Your bot (`main.py`) commands a swarm of ants in a fog-of-war RTS on a toroidal (wrap-around) grid,
in the spirit of the 2010 Ants AI Challenge. Every turn all ants move one cell at once. You only see
the map within your ants' view radius. Combat is focus-fire: an ant dies unless it has strictly fewer
enemies within attack range than every enemy attacking it (gang up to win fights). Food within spawn
radius of exactly one player's ants is gathered and spawns a new ant on a hill. Move an ant onto an
enemy hill to raze it -- razing enemy hills is the objective. Most hills razed (tiebreak: ants alive)
wins.

Your bot must implement `do_turn(obs) -> list`, returning moves [row, col, dir] with dir in
"N","S","E","W". Un-ordered ants stay; moving into water is ignored; two ants onto one cell both die.
do_turn runs in one long-lived process, so you may remember the map in module globals. `obs` is your
fog-limited view: obs["turn"]/["max_turns"], obs["rows"]/["cols"] (toroidal),
obs["viewradius2"]/["attackradius2"]/["spawnradius2"], obs["you"], obs["my_ants"], obs["my_hills"],
and (only where visible) obs["enemy_ants"], obs["enemy_hills"], obs["food"], obs["water"].

The game is played in {{rounds}} rounds. For every round, you (and your competitor) edit program
code that controls your bot. This is round {{round}}.
After you and your competitor finish editing your codebases, the game is run automatically.

Your task: improve the bot in `main.py`, located in {{working_dir}}.
{{working_dir}} is your codebase, which contains both your bot and supporting assets.
Loading