From 9d04647dbc410822aceb200c68dfe208e10d630e Mon Sep 17 00:00:00 2001 From: John Yang Date: Wed, 8 Jul 2026 14:18:57 -0400 Subject: [PATCH 1/2] Add LightCycles arena (Tron light-cycles) Thin arena wrapper cloning CodeClash-ai/LightCycles, replay renderer (trail accumulation + head/crash markers, per-sim winner via peek_winner), registration, and test config. --- codeclash/arenas/__init__.py | 2 + .../arenas/lightcycles/LightCycles.Dockerfile | 18 +++ codeclash/arenas/lightcycles/__init__.py | 0 codeclash/arenas/lightcycles/lightcycles.py | 88 ++++++++++++ codeclash/arenas/lightcycles/replay.py | 133 ++++++++++++++++++ codeclash/replay/__init__.py | 4 + configs/test/lightcycles.yaml | 30 ++++ 7 files changed, 275 insertions(+) create mode 100644 codeclash/arenas/lightcycles/LightCycles.Dockerfile create mode 100644 codeclash/arenas/lightcycles/__init__.py create mode 100644 codeclash/arenas/lightcycles/lightcycles.py create mode 100644 codeclash/arenas/lightcycles/replay.py create mode 100644 configs/test/lightcycles.yaml diff --git a/codeclash/arenas/__init__.py b/codeclash/arenas/__init__.py index bb76a922..d9e81276 100644 --- a/codeclash/arenas/__init__.py +++ b/codeclash/arenas/__init__.py @@ -15,6 +15,7 @@ from codeclash.arenas.halite2.halite2 import Halite2Arena from codeclash.arenas.halite3.halite3 import Halite3Arena from codeclash.arenas.huskybench.huskybench import HuskyBenchArena +from codeclash.arenas.lightcycles.lightcycles import LightCyclesArena from codeclash.arenas.paintvolley.paintvolley import PaintVolleyArena from codeclash.arenas.robocode.robocode import RoboCodeArena from codeclash.arenas.robotrumble.robotrumble import RobotRumbleArena @@ -37,6 +38,7 @@ Halite2Arena, Halite3Arena, HuskyBenchArena, + LightCyclesArena, PaintVolleyArena, RoboCodeArena, RobotRumbleArena, diff --git a/codeclash/arenas/lightcycles/LightCycles.Dockerfile b/codeclash/arenas/lightcycles/LightCycles.Dockerfile new file mode 100644 index 00000000..32d1da5e --- /dev/null +++ b/codeclash/arenas/lightcycles/LightCycles.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 LightCycles game repository +RUN git clone https://github.com/CodeClash-ai/LightCycles.git /workspace \ + && cd /workspace \ + && git remote set-url origin https://github.com/CodeClash-ai/LightCycles.git +WORKDIR /workspace + +# No additional dependencies needed - engine uses only the standard library diff --git a/codeclash/arenas/lightcycles/__init__.py b/codeclash/arenas/lightcycles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codeclash/arenas/lightcycles/lightcycles.py b/codeclash/arenas/lightcycles/lightcycles.py new file mode 100644 index 00000000..60025157 --- /dev/null +++ b/codeclash/arenas/lightcycles/lightcycles.py @@ -0,0 +1,88 @@ +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 + +LIGHTCYCLES_LOG = "result.log" + + +class LightCyclesArena(CodeArena): + name: str = "LightCycles" + submission: str = "main.py" + description: str = """Your bot (`main.py`) drives a cycle in LightCycles, a Tron light-cycles game. Each +player rides around a bordered grid leaving a solid trail behind. Every tick all cycles move one cell +simultaneously; you crash (and are eliminated) if you move into a wall/border, any trail (yours or an +opponent's), or the same cell as another cycle (a head-on). The last cycle riding wins; if all remaining +cycles crash on the same tick it's a draw; at the tick cap the most territory (trail cells) wins. + +Your bot must implement: + def get_move(obs: dict) -> str + +Return one of "N", "S", "E", "W" (up/down/right/left). Reversing into your own neck is ignored (you go +straight); an invalid/crashing/slow move also makes you continue straight. + +`obs` gives the full deterministic state each tick: obs["tick"]/["max_ticks"], obs["width"]/["height"], +obs["you"] (your id), obs["players"] (list of {id, x, y, dir, alive}), and obs["grid"] +(grid[y][x] = owning player id, or -1 for empty; off-grid is a wall). Origin is top-left; N is -y. +""" + + def __init__(self, config, **kwargs): + super().__init__(config, **kwargs) + assert len(config["players"]) >= 2, "LightCycles 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 / LIGHTCYCLES_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) / LIGHTCYCLES_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 get_move(" not in bot_content: + return ( + False, + f"{self.submission} must define a get_move(obs) function. " + "See the game description for the required signature.", + ) + + return True, None diff --git a/codeclash/arenas/lightcycles/replay.py b/codeclash/arenas/lightcycles/replay.py new file mode 100644 index 00000000..4878b3ae --- /dev/null +++ b/codeclash/arenas/lightcycles/replay.py @@ -0,0 +1,133 @@ +"""LightCycles replay renderer. + +Parses a per-game ``sim_*.json`` written by the engine and renders the Tron grid: +each cycle's growing trail plus its bright head, with a side panel of who's alive. + +The JSON format (see engine.py ``write_replay``):: + + {"width":40, "height":30, "num_players":2, "names":["p1","p2"], + "winner": 0 | null, + "frames":[{"t":0, "heads":[[x,y,alive], ...]}, ...]} + +Frames store only each cycle's head (``[x, y, alive]``, ``alive`` is 1/0) per tick; +the trail for a player is the set of every head cell it has occupied so far, which +the renderer rebuilds by accumulating heads up to the frame being shown. ``winner`` +is the winning player id, or ``null`` for a draw. +""" + +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', GRID_LINE = 'rgba(255,255,255,0.05)'; + let W, H, CELL; + function col(i){ return PAL[i % PAL.length]; } + function setup(cv, G){ + W = G.w; H = G.h; + CELL = Math.max(6, Math.min(20, Math.floor(640 / W))); + cv.width = W * CELL; cv.height = H * CELL; + } + function draw(ctx, cv, G, i){ + ctx.fillStyle = BG; ctx.fillRect(0, 0, cv.width, cv.height); + // faint grid + ctx.strokeStyle = GRID_LINE; ctx.lineWidth = 1; + for(let x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); } + for(let y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); } + const n = (G.frames[0].heads || []).length; + // rebuild trails by accumulating every head cell up to frame i + for(let p=0;p
` + + `` + + `${NM[p] || ('player'+(p+1))} ${alive?'':'✗'}
` + + `
trail${cells.size}
`; + } + const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : ''; + return rows + + `
tick${fr.turn} / ${G.max_ticks||''}
` + + (done ? `
result${res}
` : ''); + } + return {setup, draw, side}; +})(); +""" + + +class LightCyclesReplayer(ReplayRenderer): + arena = "LightCycles" + 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")) + w = log.get("width", 40) + h = log.get("height", 30) + 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), "heads": fr["heads"]} 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=w, + h=h, + frames=frames, + winner=winner, + draw=draw, + extra={"names": names, "num_players": n, "max_ticks": log.get("max_ticks")}, + ) + + 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..05e1f3eb 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 == "LightCycles": + from codeclash.arenas.lightcycles.replay import LightCyclesReplayer + + return LightCyclesReplayer() if arena == "Halite": from codeclash.arenas.halite.replay import HaliteReplayer diff --git a/configs/test/lightcycles.yaml b/configs/test/lightcycles.yaml new file mode 100644 index 00000000..cedf6f5c --- /dev/null +++ b/configs/test/lightcycles.yaml @@ -0,0 +1,30 @@ +tournament: + rounds: 3 +game: + name: LightCycles + sims_per_round: 10 +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 LightCycles. + Your bot (`main.py`) drives a cycle in a Tron light-cycles game. Each player rides around a + bordered grid leaving a solid trail behind. Every tick all cycles move one cell simultaneously; + you crash (and are eliminated) if you move into a wall/border, any trail (yours or an opponent's), + or the same cell as another cycle (a head-on). The last cycle riding wins; if all remaining cycles + crash on the same tick it's a draw; at the tick cap the most territory (trail cells) wins. + + Your bot must implement `get_move(obs) -> str`, returning one of "N", "S", "E", "W" + (up/down/right/left). `obs` is the full deterministic state each tick: obs["tick"]/["max_ticks"], + obs["width"]/["height"], obs["you"] (your id), obs["players"] (list of {id, x, y, dir, alive}), and + obs["grid"] (grid[y][x] = owning player id, or -1 for empty; off-grid is a wall). Origin is top-left. + + 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 71314886b453bab9203e16d5c4ef2cb6653f9a17 Mon Sep 17 00:00:00 2001 From: John Yang Date: Wed, 8 Jul 2026 17:51:06 -0400 Subject: [PATCH 2/2] LightCycles: render rock obstacles + note them in arena description --- codeclash/arenas/lightcycles/lightcycles.py | 11 ++++++----- codeclash/arenas/lightcycles/replay.py | 12 ++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/codeclash/arenas/lightcycles/lightcycles.py b/codeclash/arenas/lightcycles/lightcycles.py index 60025157..6dd53d54 100644 --- a/codeclash/arenas/lightcycles/lightcycles.py +++ b/codeclash/arenas/lightcycles/lightcycles.py @@ -12,10 +12,11 @@ class LightCyclesArena(CodeArena): name: str = "LightCycles" submission: str = "main.py" description: str = """Your bot (`main.py`) drives a cycle in LightCycles, a Tron light-cycles game. Each -player rides around a bordered grid leaving a solid trail behind. Every tick all cycles move one cell -simultaneously; you crash (and are eliminated) if you move into a wall/border, any trail (yours or an -opponent's), or the same cell as another cycle (a head-on). The last cycle riding wins; if all remaining -cycles crash on the same tick it's a draw; at the tick cap the most territory (trail cells) wins. +player rides around a bordered grid leaving a solid trail behind. The board may contain static rock +obstacles too. Every tick all cycles move one cell simultaneously; you crash (and are eliminated) if you +move into a wall/border, a rock, any trail (yours or an opponent's), or the same cell as another cycle (a +head-on). The last cycle riding wins; if all remaining cycles crash on the same tick it's a draw; at the +tick cap the most territory (trail cells) wins. Your bot must implement: def get_move(obs: dict) -> str @@ -25,7 +26,7 @@ def get_move(obs: dict) -> str `obs` gives the full deterministic state each tick: obs["tick"]/["max_ticks"], obs["width"]/["height"], obs["you"] (your id), obs["players"] (list of {id, x, y, dir, alive}), and obs["grid"] -(grid[y][x] = owning player id, or -1 for empty; off-grid is a wall). Origin is top-left; N is -y. +(grid[y][x] = player id >=0, -1 empty, or -2 rock; off-grid is a wall). Origin is top-left; N is -y. """ def __init__(self, config, **kwargs): diff --git a/codeclash/arenas/lightcycles/replay.py b/codeclash/arenas/lightcycles/replay.py index 4878b3ae..eb6729df 100644 --- a/codeclash/arenas/lightcycles/replay.py +++ b/codeclash/arenas/lightcycles/replay.py @@ -24,7 +24,7 @@ DRAW_JS = """ const ARENA = (function(){ const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594']; - const BG = '#0d1117', GRID_LINE = 'rgba(255,255,255,0.05)'; + const BG = '#0d1117', GRID_LINE = 'rgba(255,255,255,0.05)', ROCK_COL = '#4b5563'; let W, H, CELL; function col(i){ return PAL[i % PAL.length]; } function setup(cv, G){ @@ -38,6 +38,9 @@ ctx.strokeStyle = GRID_LINE; ctx.lineWidth = 1; for(let x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); } for(let y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); } + // static rock obstacles + ctx.fillStyle = ROCK_COL; + (G.rocks || []).forEach(r => ctx.fillRect(r[0]*CELL, r[1]*CELL, CELL, CELL)); const n = (G.frames[0].heads || []).length; // rebuild trails by accumulating every head cell up to frame i for(let p=0;p ReplayData: frames=frames, winner=winner, draw=draw, - extra={"names": names, "num_players": n, "max_ticks": log.get("max_ticks")}, + extra={ + "names": names, + "num_players": n, + "max_ticks": log.get("max_ticks"), + "rocks": log.get("rocks", []), + }, ) def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None: