From 37b5a5dfb0987189f69f4afe87ec2b1788c062a4 Mon Sep 17 00:00:00 2001 From: John Yang Date: Thu, 9 Jul 2026 10:21:06 -0400 Subject: [PATCH 1/3] 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. --- codeclash/arenas/__init__.py | 2 + codeclash/arenas/ants/Ants.Dockerfile | 18 ++++ codeclash/arenas/ants/__init__.py | 0 codeclash/arenas/ants/ants.py | 92 +++++++++++++++++ codeclash/arenas/ants/replay.py | 138 ++++++++++++++++++++++++++ codeclash/replay/__init__.py | 4 + configs/test/ants.yaml | 34 +++++++ 7 files changed, 288 insertions(+) create mode 100644 codeclash/arenas/ants/Ants.Dockerfile create mode 100644 codeclash/arenas/ants/__init__.py create mode 100644 codeclash/arenas/ants/ants.py create mode 100644 codeclash/arenas/ants/replay.py create mode 100644 configs/test/ants.yaml diff --git a/codeclash/arenas/__init__.py b/codeclash/arenas/__init__.py index bb76a922..5dc3ea10 100644 --- a/codeclash/arenas/__init__.py +++ b/codeclash/arenas/__init__.py @@ -1,4 +1,5 @@ from codeclash.arenas.arena import CodeArena +from codeclash.arenas.ants.ants import AntsArena from codeclash.arenas.battlecode23.battlecode23 import BattleCode23Arena from codeclash.arenas.battlecode24.battlecode24 import BattleCode24Arena from codeclash.arenas.battlecode25.battlecode25 import BattleCode25Arena @@ -21,6 +22,7 @@ from codeclash.arenas.scml.scml import SCMLOneShotArena ARENAS = [ + AntsArena, BattleCode23Arena, BattleCode24Arena, BattleCode25Arena, diff --git a/codeclash/arenas/ants/Ants.Dockerfile b/codeclash/arenas/ants/Ants.Dockerfile new file mode 100644 index 00000000..ef6a1525 --- /dev/null +++ b/codeclash/arenas/ants/Ants.Dockerfile @@ -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 diff --git a/codeclash/arenas/ants/__init__.py b/codeclash/arenas/ants/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codeclash/arenas/ants/ants.py b/codeclash/arenas/ants/ants.py new file mode 100644 index 00000000..2e44a07d --- /dev/null +++ b/codeclash/arenas/ants/ants.py @@ -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_: games won ()". 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 diff --git a/codeclash/arenas/ants/replay.py b/codeclash/arenas/ants/replay.py new file mode 100644 index 00000000..93236382 --- /dev/null +++ b/codeclash/arenas/ants/replay.py @@ -0,0 +1,138 @@ +"""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 (large bordered square in the owner's color) + (f.hills||[]).forEach(h => { + const x = h[1]*CELL, y = h[0]*CELL; + ctx.fillStyle = col(h[2]); ctx.globalAlpha = 0.35; ctx.fillRect(x, y, CELL, CELL); ctx.globalAlpha = 1; + ctx.strokeStyle = col(h[2]); ctx.lineWidth = 2; ctx.strokeRect(x+1.5, y+1.5, CELL-3, CELL-3); + }); + // 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
` + + `${NM[p] || ('player'+(p+1))}
` + + `
ants${ants[p]||0}
` + + `
hills${hills[p]||0}
`; + } + const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : ''; + return rows + + `
turn${f.turn} / ${G.max_turns||''}
` + + (done ? `
result${res}
` : ''); + } + 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) diff --git a/codeclash/replay/__init__.py b/codeclash/replay/__init__.py index e053c4da..b97e6855 100644 --- a/codeclash/replay/__init__.py +++ b/codeclash/replay/__init__.py @@ -61,6 +61,10 @@ def get_replayer(arena: str) -> ReplayRenderer | None: from codeclash.arenas.paintvolley.replay import PaintVolleyReplayer return PaintVolleyReplayer() + if arena == "Ants": + from codeclash.arenas.ants.replay import AntsReplayer + + return AntsReplayer() if arena == "Halite": from codeclash.arenas.halite.replay import HaliteReplayer diff --git a/configs/test/ants.yaml b/configs/test/ants.yaml new file mode 100644 index 00000000..94dfd37a --- /dev/null +++ b/configs/test/ants.yaml @@ -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. From 7890944a056c0e04ce3a111801770ce945f102c2 Mon Sep 17 00:00:00 2001 From: John Yang Date: Thu, 9 Jul 2026 12:48:44 -0400 Subject: [PATCH 2/3] Ants replay: clearer anthill glyph (mound + entrance + owner ring) --- codeclash/arenas/ants/replay.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/codeclash/arenas/ants/replay.py b/codeclash/arenas/ants/replay.py index 93236382..04ba0ef2 100644 --- a/codeclash/arenas/ants/replay.py +++ b/codeclash/arenas/ants/replay.py @@ -43,11 +43,27 @@ 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 (large bordered square in the owner's color) + // 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 x = h[1]*CELL, y = h[0]*CELL; - ctx.fillStyle = col(h[2]); ctx.globalAlpha = 0.35; ctx.fillRect(x, y, CELL, CELL); ctx.globalAlpha = 1; - ctx.strokeStyle = col(h[2]); ctx.lineWidth = 2; ctx.strokeRect(x+1.5, y+1.5, CELL-3, CELL-3); + 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; From 34d64f57017ed6f6693b1fcc97759b4a83edb80a Mon Sep 17 00:00:00 2001 From: John Yang Date: Thu, 9 Jul 2026 16:44:18 -0400 Subject: [PATCH 3/3] 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) --- codeclash/arenas/__init__.py | 2 +- codeclash/arenas/ants/replay.py | 7 ++++++- codeclash/tournaments/single_player.py | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/codeclash/arenas/__init__.py b/codeclash/arenas/__init__.py index 5dc3ea10..49384ae2 100644 --- a/codeclash/arenas/__init__.py +++ b/codeclash/arenas/__init__.py @@ -1,5 +1,5 @@ -from codeclash.arenas.arena import CodeArena 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 from codeclash.arenas.battlecode25.battlecode25 import BattleCode25Arena diff --git a/codeclash/arenas/ants/replay.py b/codeclash/arenas/ants/replay.py index 04ba0ef2..4d115dd1 100644 --- a/codeclash/arenas/ants/replay.py +++ b/codeclash/arenas/ants/replay.py @@ -120,7 +120,12 @@ def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData: 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", [])} + { + "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", [])) ] diff --git a/codeclash/tournaments/single_player.py b/codeclash/tournaments/single_player.py index 8fe4fdaf..f3ce08ce 100644 --- a/codeclash/tournaments/single_player.py +++ b/codeclash/tournaments/single_player.py @@ -62,6 +62,9 @@ def get_game_context(self, agent_config: dict, *, round: int) -> GameContext: def get_agent(self, agent_config: dict, round: int) -> Player: """Create an agent with environment and game context.""" + # Commit/tag messages are prefixed with `commit_label` (see Player._round_message); + # single-player has no rung/opponent prefix, so default it to empty (as PvP does). + agent_config.setdefault("commit_label", "") environment = self.game.get_environment(f"{self.game.game_id}.{agent_config['name']}") game_context = self.get_game_context(agent_config, round=round) return get_agent(agent_config, game_context, environment)