Skip to content
Open
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
Expand Up @@ -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
Expand All @@ -37,6 +38,7 @@
Halite2Arena,
Halite3Arena,
HuskyBenchArena,
LightCyclesArena,
PaintVolleyArena,
RoboCodeArena,
RobotRumbleArena,
Expand Down
18 changes: 18 additions & 0 deletions codeclash/arenas/lightcycles/LightCycles.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 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
Empty file.
89 changes: 89 additions & 0 deletions codeclash/arenas/lightcycles/lightcycles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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. 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

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] = 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):
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_<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 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
141 changes: 141 additions & 0 deletions codeclash/arenas/lightcycles/replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""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)', ROCK_COL = '#4b5563';
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(); }
// 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<n;p++){
ctx.fillStyle = col(p); ctx.globalAlpha = 0.55;
for(let f=0;f<=i;f++){
const h = G.frames[f].heads[p];
ctx.fillRect(h[0]*CELL, h[1]*CELL, CELL, CELL);
}
ctx.globalAlpha = 1;
}
// heads on top (bright, outlined); crashed cycles get an X
const fr = G.frames[i];
for(let p=0;p<n;p++){
const h = fr.heads[p], hx = h[0]*CELL, hy = h[1]*CELL;
ctx.fillStyle = col(p);
ctx.fillRect(hx, hy, CELL, CELL);
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
ctx.strokeRect(hx+1, hy+1, CELL-2, CELL-2);
if(!h[2]){ // crashed
ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(hx+3, hy+3); ctx.lineTo(hx+CELL-3, hy+CELL-3);
ctx.moveTo(hx+CELL-3, hy+3); ctx.lineTo(hx+3, hy+CELL-3);
ctx.stroke();
}
}
}
function side(G, i){
const fr = G.frames[i], NM = G.names || [], n = fr.heads.length;
const done = i === G.frames.length - 1;
let rows = '';
for(let p=0;p<n;p++){
const alive = fr.heads[p][2];
// territory = distinct cells this cycle has occupied through frame i
const cells = new Set();
for(let f=0;f<=i;f++){ const h=G.frames[f].heads[p]; cells.add(h[0]+','+h[1]); }
rows += `<div class="team ${alive?'':'tdead'}"><div class="tname">`
+ `<span class="sw" style="background:${col(p)}"></span>`
+ `${NM[p] || ('player'+(p+1))} ${alive?'':'&#10007;'}</div>`
+ `<div class="stat"><span>trail</span><b>${cells.size}</b></div></div>`;
}
const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : '';
return rows
+ `<div class="stat"><span>tick</span><b>${fr.turn} / ${G.max_ticks||''}</b></div>`
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
}
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"),
"rocks": log.get("rocks", []),
},
)

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 @@ -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

Expand Down
30 changes: 30 additions & 0 deletions configs/test/lightcycles.yaml
Original file line number Diff line number Diff line change
@@ -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.
Loading