Skip to content

Commit 9d04647

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

7 files changed

Lines changed: 275 additions & 0 deletions

File tree

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from codeclash.arenas.halite2.halite2 import Halite2Arena
1616
from codeclash.arenas.halite3.halite3 import Halite3Arena
1717
from codeclash.arenas.huskybench.huskybench import HuskyBenchArena
18+
from codeclash.arenas.lightcycles.lightcycles import LightCyclesArena
1819
from codeclash.arenas.paintvolley.paintvolley import PaintVolleyArena
1920
from codeclash.arenas.robocode.robocode import RoboCodeArena
2021
from codeclash.arenas.robotrumble.robotrumble import RobotRumbleArena
@@ -37,6 +38,7 @@
3738
Halite2Arena,
3839
Halite3Arena,
3940
HuskyBenchArena,
41+
LightCyclesArena,
4042
PaintVolleyArena,
4143
RoboCodeArena,
4244
RobotRumbleArena,
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 LightCycles game repository
13+
RUN git clone https://github.com/CodeClash-ai/LightCycles.git /workspace \
14+
&& cd /workspace \
15+
&& git remote set-url origin https://github.com/CodeClash-ai/LightCycles.git
16+
WORKDIR /workspace
17+
18+
# No additional dependencies needed - engine uses only the standard library

codeclash/arenas/lightcycles/__init__.py

Whitespace-only changes.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
LIGHTCYCLES_LOG = "result.log"
9+
10+
11+
class LightCyclesArena(CodeArena):
12+
name: str = "LightCycles"
13+
submission: str = "main.py"
14+
description: str = """Your bot (`main.py`) drives a cycle in LightCycles, a Tron light-cycles game. Each
15+
player rides around a bordered grid leaving a solid trail behind. Every tick all cycles move one cell
16+
simultaneously; you crash (and are eliminated) if you move into a wall/border, any trail (yours or an
17+
opponent's), or the same cell as another cycle (a head-on). The last cycle riding wins; if all remaining
18+
cycles crash on the same tick it's a draw; at the tick cap the most territory (trail cells) wins.
19+
20+
Your bot must implement:
21+
def get_move(obs: dict) -> str
22+
23+
Return one of "N", "S", "E", "W" (up/down/right/left). Reversing into your own neck is ignored (you go
24+
straight); an invalid/crashing/slow move also makes you continue straight.
25+
26+
`obs` gives the full deterministic state each tick: obs["tick"]/["max_ticks"], obs["width"]/["height"],
27+
obs["you"] (your id), obs["players"] (list of {id, x, y, dir, alive}), and obs["grid"]
28+
(grid[y][x] = owning player id, or -1 for empty; off-grid is a wall). Origin is top-left; N is -y.
29+
"""
30+
31+
def __init__(self, config, **kwargs):
32+
super().__init__(config, **kwargs)
33+
assert len(config["players"]) >= 2, "LightCycles needs at least two players"
34+
35+
def execute_round(self, agents: list[Player]) -> None:
36+
args = [f"/{agent.name}/{self.submission}" for agent in agents]
37+
cmd = (
38+
f"python engine.py {' '.join(args)} -r {self.game_config['sims_per_round']} "
39+
f"-o {self.log_env} > {self.log_env / LIGHTCYCLES_LOG};"
40+
)
41+
self.logger.info(f"Running game: {cmd}")
42+
assert_zero_exit_code(self.environment.execute(cmd))
43+
44+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
45+
with open(self.log_round(round_num) / LIGHTCYCLES_LOG) as f:
46+
round_log = f.read()
47+
lines = round_log.split("FINAL_RESULTS")[-1].splitlines()
48+
49+
# Engine prints: "Bot_<i>: <wins> games won (<name>)". Score = games won.
50+
scores: dict[str, float] = {}
51+
for line in lines:
52+
match = re.search(r"Bot_(\d+):\s+(\d+)\s+games\s+won", line)
53+
if match:
54+
bot_id = int(match.group(1))
55+
wins = int(match.group(2))
56+
if 1 <= bot_id <= len(agents):
57+
scores[agents[bot_id - 1].name] = wins
58+
59+
draw_match = re.search(r"Draws:\s+(\d+)", round_log)
60+
if draw_match and int(draw_match.group(1)) > 0:
61+
scores[RESULT_TIE] = int(draw_match.group(1))
62+
63+
if scores:
64+
real = {k: v for k, v in scores.items() if k != RESULT_TIE}
65+
max_score = max(real.values()) if real else 0
66+
winners = [k for k, v in real.items() if v == max_score]
67+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
68+
else:
69+
stats.winner = "unknown"
70+
71+
stats.scores = scores
72+
for player, score in scores.items():
73+
if player != RESULT_TIE:
74+
stats.player_stats[player].score = score
75+
76+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
77+
if self.submission not in agent.environment.execute("ls")["output"]:
78+
return False, f"No {self.submission} file found in the root directory"
79+
80+
bot_content = agent.environment.execute(f"cat {self.submission}")["output"]
81+
if "def get_move(" not in bot_content:
82+
return (
83+
False,
84+
f"{self.submission} must define a get_move(obs) function. "
85+
"See the game description for the required signature.",
86+
)
87+
88+
return True, None
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""LightCycles replay renderer.
2+
3+
Parses a per-game ``sim_*.json`` written by the engine and renders the Tron grid:
4+
each cycle's growing trail plus its bright head, with a side panel of who's alive.
5+
6+
The JSON format (see engine.py ``write_replay``)::
7+
8+
{"width":40, "height":30, "num_players":2, "names":["p1","p2"],
9+
"winner": 0 | null,
10+
"frames":[{"t":0, "heads":[[x,y,alive], ...]}, ...]}
11+
12+
Frames store only each cycle's head (``[x, y, alive]``, ``alive`` is 1/0) per tick;
13+
the trail for a player is the set of every head cell it has occupied so far, which
14+
the renderer rebuilds by accumulating heads up to the frame being shown. ``winner``
15+
is the winning player id, or ``null`` for a draw.
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', GRID_LINE = 'rgba(255,255,255,0.05)';
28+
let W, H, CELL;
29+
function col(i){ return PAL[i % PAL.length]; }
30+
function setup(cv, G){
31+
W = G.w; H = G.h;
32+
CELL = Math.max(6, Math.min(20, Math.floor(640 / W)));
33+
cv.width = W * CELL; cv.height = H * CELL;
34+
}
35+
function draw(ctx, cv, G, i){
36+
ctx.fillStyle = BG; ctx.fillRect(0, 0, cv.width, cv.height);
37+
// faint grid
38+
ctx.strokeStyle = GRID_LINE; ctx.lineWidth = 1;
39+
for(let x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); }
40+
for(let y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); }
41+
const n = (G.frames[0].heads || []).length;
42+
// rebuild trails by accumulating every head cell up to frame i
43+
for(let p=0;p<n;p++){
44+
ctx.fillStyle = col(p); ctx.globalAlpha = 0.55;
45+
for(let f=0;f<=i;f++){
46+
const h = G.frames[f].heads[p];
47+
ctx.fillRect(h[0]*CELL, h[1]*CELL, CELL, CELL);
48+
}
49+
ctx.globalAlpha = 1;
50+
}
51+
// heads on top (bright, outlined); crashed cycles get an X
52+
const fr = G.frames[i];
53+
for(let p=0;p<n;p++){
54+
const h = fr.heads[p], hx = h[0]*CELL, hy = h[1]*CELL;
55+
ctx.fillStyle = col(p);
56+
ctx.fillRect(hx, hy, CELL, CELL);
57+
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
58+
ctx.strokeRect(hx+1, hy+1, CELL-2, CELL-2);
59+
if(!h[2]){ // crashed
60+
ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2;
61+
ctx.beginPath();
62+
ctx.moveTo(hx+3, hy+3); ctx.lineTo(hx+CELL-3, hy+CELL-3);
63+
ctx.moveTo(hx+CELL-3, hy+3); ctx.lineTo(hx+3, hy+CELL-3);
64+
ctx.stroke();
65+
}
66+
}
67+
}
68+
function side(G, i){
69+
const fr = G.frames[i], NM = G.names || [], n = fr.heads.length;
70+
const done = i === G.frames.length - 1;
71+
let rows = '';
72+
for(let p=0;p<n;p++){
73+
const alive = fr.heads[p][2];
74+
// territory = distinct cells this cycle has occupied through frame i
75+
const cells = new Set();
76+
for(let f=0;f<=i;f++){ const h=G.frames[f].heads[p]; cells.add(h[0]+','+h[1]); }
77+
rows += `<div class="team ${alive?'':'tdead'}"><div class="tname">`
78+
+ `<span class="sw" style="background:${col(p)}"></span>`
79+
+ `${NM[p] || ('player'+(p+1))} ${alive?'':'&#10007;'}</div>`
80+
+ `<div class="stat"><span>trail</span><b>${cells.size}</b></div></div>`;
81+
}
82+
const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : '';
83+
return rows
84+
+ `<div class="stat"><span>tick</span><b>${fr.turn} / ${G.max_ticks||''}</b></div>`
85+
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
86+
}
87+
return {setup, draw, side};
88+
})();
89+
"""
90+
91+
92+
class LightCyclesReplayer(ReplayRenderer):
93+
arena = "LightCycles"
94+
sim_glob = "sim_*.json"
95+
DRAW_JS = DRAW_JS
96+
97+
def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
98+
log = json.loads(raw.decode(errors="replace"))
99+
w = log.get("width", 40)
100+
h = log.get("height", 30)
101+
n = log.get("num_players", 2)
102+
103+
names = list(log.get("names", []))
104+
if players:
105+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
106+
while len(names) < n:
107+
names.append(f"player{len(names) + 1}")
108+
109+
frames = [{"turn": fr.get("t", idx), "heads": fr["heads"]} for idx, fr in enumerate(log.get("frames", []))]
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=w,
117+
h=h,
118+
frames=frames,
119+
winner=winner,
120+
draw=draw,
121+
extra={"names": names, "num_players": n, "max_ticks": log.get("max_ticks")},
122+
)
123+
124+
def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None:
125+
log = json.loads(raw.decode(errors="replace"))
126+
win = log.get("winner")
127+
if win is None:
128+
return (None, True)
129+
names = list(log.get("names", []))
130+
if players:
131+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
132+
name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
133+
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 == "LightCycles":
65+
from codeclash.arenas.lightcycles.replay import LightCyclesReplayer
66+
67+
return LightCyclesReplayer()
6468
if arena == "Halite":
6569
from codeclash.arenas.halite.replay import HaliteReplayer
6670

configs/test/lightcycles.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
tournament:
2+
rounds: 3
3+
game:
4+
name: LightCycles
5+
sims_per_round: 10
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 LightCycles.
14+
Your bot (`main.py`) drives a cycle in a Tron light-cycles game. Each player rides around a
15+
bordered grid leaving a solid trail behind. Every tick all cycles move one cell simultaneously;
16+
you crash (and are eliminated) if you move into a wall/border, any trail (yours or an opponent's),
17+
or the same cell as another cycle (a head-on). The last cycle riding wins; if all remaining cycles
18+
crash on the same tick it's a draw; at the tick cap the most territory (trail cells) wins.
19+
20+
Your bot must implement `get_move(obs) -> str`, returning one of "N", "S", "E", "W"
21+
(up/down/right/left). `obs` is the full deterministic state each tick: obs["tick"]/["max_ticks"],
22+
obs["width"]/["height"], obs["you"] (your id), obs["players"] (list of {id, x, y, dir, alive}), and
23+
obs["grid"] (grid[y][x] = owning player id, or -1 for empty; off-grid is a wall). Origin is top-left.
24+
25+
The game is played in {{rounds}} rounds. For every round, you (and your competitor) edit program
26+
code that controls your bot. This is round {{round}}.
27+
After you and your competitor finish editing your codebases, the game is run automatically.
28+
29+
Your task: improve the bot in `main.py`, located in {{working_dir}}.
30+
{{working_dir}} is your codebase, which contains both your bot and supporting assets.

0 commit comments

Comments
 (0)