Skip to content

Commit e3d90a7

Browse files
committed
Add PaintVolley arena
1 parent 381cdfa commit e3d90a7

8 files changed

Lines changed: 355 additions & 3 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.paintvolley.paintvolley import PaintVolleyArena
1819
from codeclash.arenas.robocode.robocode import RoboCodeArena
1920
from codeclash.arenas.robotrumble.robotrumble import RobotRumbleArena
2021
from codeclash.arenas.scml.scml import SCMLOneShotArena
@@ -36,6 +37,7 @@
3637
Halite2Arena,
3738
Halite3Arena,
3839
HuskyBenchArena,
40+
PaintVolleyArena,
3941
RoboCodeArena,
4042
RobotRumbleArena,
4143
SCMLOneShotArena,
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 PaintVolley game repository
13+
RUN git clone https://github.com/CodeClash-ai/PaintVolley.git /workspace \
14+
&& cd /workspace \
15+
&& git remote set-url origin https://github.com/CodeClash-ai/PaintVolley.git
16+
WORKDIR /workspace
17+
18+
# No additional dependencies needed - engine uses only the standard library

codeclash/arenas/paintvolley/__init__.py

Whitespace-only changes.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
PAINTVOLLEY_LOG = "result.log"
9+
10+
11+
class PaintVolleyArena(CodeArena):
12+
name: str = "PaintVolley"
13+
submission: str = "main.py"
14+
description: str = """Your bot (`main.py`) controls a helmet-wearing character in PaintVolley, a
15+
territory-painting game. Characters patrol the bottom of a rectangular field while balls fly around
16+
bouncing off all four walls (no gravity). Each tick, every ball paints the tile it is over in its own
17+
color. Balls start neutral and paint nothing; when a ball lands on your helmet it turns your color and
18+
bounces upward, so it then paints for you until an opponent steals it. The helmet-contact point sets
19+
the exit angle (center = straight up, edges = steep), so you aim where paint goes. Most tiles owned
20+
when the tick budget runs out wins.
21+
22+
Your bot must implement:
23+
def get_action(obs: dict) -> str
24+
25+
Return one of: "LEFT", "RIGHT", "JUMP", "JUMP_LEFT", "JUMP_RIGHT", "NONE".
26+
Invalid/crashing/slow actions are treated as "NONE" for that tick.
27+
28+
`obs` is the full deterministic state each tick: obs["tick"]/["max_ticks"], obs["field"]
29+
(width/height/cols/rows), obs["rules"] (every physics constant, so you can forward-simulate),
30+
obs["you"] (id/color), obs["players"] (id, color, x, y, on_ground), obs["balls"]
31+
(x, y, vx, vy, color where color is the owner id or -1 for neutral), obs["tiles"]
32+
(tiles[row][col] = owner id or -1), and obs["scores"]. Origin is top-left; up is -y.
33+
"""
34+
35+
def __init__(self, config, **kwargs):
36+
super().__init__(config, **kwargs)
37+
assert len(config["players"]) >= 2, "PaintVolley needs at least two players"
38+
39+
def execute_round(self, agents: list[Player]) -> None:
40+
args = [f"/{agent.name}/{self.submission}" for agent in agents]
41+
cmd = (
42+
f"python engine.py {' '.join(args)} -r {self.game_config['sims_per_round']} "
43+
f"-o {self.log_env} > {self.log_env / PAINTVOLLEY_LOG};"
44+
)
45+
self.logger.info(f"Running game: {cmd}")
46+
assert_zero_exit_code(self.environment.execute(cmd))
47+
48+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
49+
with open(self.log_round(round_num) / PAINTVOLLEY_LOG) as f:
50+
round_log = f.read()
51+
lines = round_log.split("FINAL_RESULTS")[-1].splitlines()
52+
53+
# Engine prints: "Bot_<i>: <wins> games won, <avg> avg_tiles (<name>)".
54+
# Score a bot by games won (1-indexed bot id maps to agents[id-1]).
55+
scores: dict[str, float] = {}
56+
for line in lines:
57+
match = re.search(r"Bot_(\d+):\s+(\d+)\s+games\s+won", line)
58+
if match:
59+
bot_id = int(match.group(1))
60+
wins = int(match.group(2))
61+
if 1 <= bot_id <= len(agents):
62+
scores[agents[bot_id - 1].name] = wins
63+
64+
draw_match = re.search(r"Draws:\s+(\d+)", round_log)
65+
if draw_match and int(draw_match.group(1)) > 0:
66+
scores[RESULT_TIE] = int(draw_match.group(1))
67+
68+
if scores:
69+
real = {k: v for k, v in scores.items() if k != RESULT_TIE}
70+
max_score = max(real.values()) if real else 0
71+
winners = [k for k, v in real.items() if v == max_score]
72+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
73+
else:
74+
stats.winner = "unknown"
75+
76+
stats.scores = scores
77+
for player, score in scores.items():
78+
if player != RESULT_TIE:
79+
stats.player_stats[player].score = score
80+
81+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
82+
if self.submission not in agent.environment.execute("ls")["output"]:
83+
return False, f"No {self.submission} file found in the root directory"
84+
85+
bot_content = agent.environment.execute(f"cat {self.submission}")["output"]
86+
if "def get_action(" not in bot_content:
87+
return (
88+
False,
89+
f"{self.submission} must define a get_action(obs) function. "
90+
"See the game description for the required signature.",
91+
)
92+
93+
return True, None
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""PaintVolley replay renderer.
2+
3+
Parses a per-game ``sim_*.json`` written by the engine into one frame per recorded
4+
tick and renders the painted tile grid, the flying balls, and the helmet characters
5+
patrolling the bottom, with a live scoreboard.
6+
7+
The JSON format (see engine.py ``write_replay``)::
8+
9+
{"cols":32, "rows":24, "num_players":2, "max_ticks":1500,
10+
"names":["p1","p2"], "winner": 0 | null, "final_scores": {"0":399,"1":255},
11+
"frames":[{"tick":0, "grid":["...01.",...], "balls":[{"x","y","c"}],
12+
"players":[{"x","y","id"}], "scores":{"0":n,"1":m}}, ...]}
13+
14+
Each frame's ``grid`` is one string per row; each char is the tile owner encoded in
15+
base36 (``'0'``..), or ``'.'`` for a neutral (unpainted) tile. Ball/player ``color``
16+
values are player ids (0-based) or ``-1`` for an unclaimed ball. ``winner`` is the
17+
winning player id, or ``null`` for a draw.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
24+
from codeclash.replay.base import ReplayData, ReplayRenderer
25+
26+
DRAW_JS = """
27+
const ARENA = (function(){
28+
// Visual constants mirror the engine (BALL_RADIUS, PLAYER_HALF_WIDTH, PLAYER_HEIGHT).
29+
const CELL = 18, BALL_R = 0.6, BODY_HW = 0.9, PH = 2.5;
30+
// Player palette (index by player id); neutral tiles use the dark background.
31+
const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594'];
32+
const NEUTRAL = '#161b22';
33+
let COLS, ROWS;
34+
function col(c){ return c < 0 ? NEUTRAL : PAL[c % PAL.length]; }
35+
function setup(cv, G){
36+
COLS = G.w; ROWS = G.h;
37+
cv.width = COLS * CELL; cv.height = ROWS * CELL;
38+
}
39+
function draw(ctx, cv, G, i){
40+
const f = G.frames[i];
41+
// background
42+
ctx.fillStyle = NEUTRAL; ctx.fillRect(0, 0, cv.width, cv.height);
43+
// painted tiles (each row is a string; '.' = neutral, else base36 owner id)
44+
for(let r=0;r<ROWS;r++){
45+
const row = f.grid[r];
46+
for(let c=0;c<COLS;c++){
47+
const ch = row[c];
48+
if(ch !== '.'){ ctx.fillStyle = col(parseInt(ch, 36)); ctx.fillRect(c*CELL, r*CELL, CELL, CELL); }
49+
}
50+
}
51+
// faint grid lines
52+
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1;
53+
for(let c=0;c<=COLS;c++){ ctx.beginPath(); ctx.moveTo(c*CELL,0); ctx.lineTo(c*CELL,cv.height); ctx.stroke(); }
54+
for(let r=0;r<=ROWS;r++){ ctx.beginPath(); ctx.moveTo(0,r*CELL); ctx.lineTo(cv.width,r*CELL); ctx.stroke(); }
55+
// players — each a distinct fixed-size character (helmet head + body) whose feet
56+
// rest at helmet_y + PH. When it jumps, helmet_y decreases so the whole figure
57+
// lifts off the floor (a gap opens under its feet) instead of stretching. A shadow
58+
// on the floor shrinks with height to sell the jump.
59+
const FLOOR = ROWS*CELL;
60+
(f.players||[]).forEach(p=>{
61+
const px = p.x*CELL, py = p.y*CELL, bw = BODY_HW*2*CELL, cc = col(p.id);
62+
const feet = py + PH*CELL; // fixed offset below the helmet
63+
const lift = Math.max(0, FLOOR - feet); // how far off the floor (px)
64+
// floor shadow (shrinks as the character rises)
65+
const sw = (bw/2) * (1 - Math.min(0.6, lift/(6*CELL)));
66+
ctx.fillStyle = 'rgba(0,0,0,0.28)';
67+
ctx.beginPath(); ctx.ellipse(px, FLOOR-3, Math.max(3, sw), 3.5, 0, 0, 7); ctx.fill();
68+
// body (fixed height, from just under the head down to the feet)
69+
const hr = bw/2, hcy = py + hr*0.85;
70+
ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 2; ctx.fillStyle = cc;
71+
ctx.beginPath(); ctx.roundRect(px-bw/2, hcy, bw, Math.max(6, feet-hcy), Math.min(7, bw/2)); ctx.fill(); ctx.stroke();
72+
// head + helmet rim
73+
ctx.beginPath(); ctx.arc(px, hcy, hr, 0, 7); ctx.fillStyle = cc; ctx.fill(); ctx.stroke();
74+
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 3;
75+
ctx.beginPath(); ctx.arc(px, hcy, hr, Math.PI, 2*Math.PI); ctx.stroke();
76+
// eyes
77+
ctx.fillStyle = '#fff';
78+
ctx.beginPath(); ctx.arc(px-hr*0.33, hcy+2, 2.4, 0, 7); ctx.arc(px+hr*0.33, hcy+2, 2.4, 0, 7); ctx.fill();
79+
ctx.fillStyle = '#111';
80+
ctx.beginPath(); ctx.arc(px-hr*0.33, hcy+2, 1.1, 0, 7); ctx.arc(px+hr*0.33, hcy+2, 1.1, 0, 7); ctx.fill();
81+
});
82+
// balls — glow ring + body + highlight so the color (and who owns it) reads clearly
83+
(f.balls||[]).forEach(b=>{
84+
const bx = b.x*CELL, by = b.y*CELL, br = BALL_R*CELL, cc = b.c < 0 ? '#c9d1d9' : col(b.c);
85+
ctx.globalAlpha = 0.25; ctx.fillStyle = cc;
86+
ctx.beginPath(); ctx.arc(bx, by, br+3, 0, 7); ctx.fill();
87+
ctx.globalAlpha = 1;
88+
ctx.beginPath(); ctx.arc(bx, by, br, 0, 7); ctx.fillStyle = cc; ctx.fill();
89+
ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2; ctx.stroke();
90+
ctx.beginPath(); ctx.arc(bx-br*0.3, by-br*0.3, br*0.35, 0, 7); ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.fill();
91+
});
92+
}
93+
function side(G, i){
94+
const f = G.frames[i], NM = G.names || [], total = COLS*ROWS;
95+
const done = i === G.frames.length - 1;
96+
let rows = '';
97+
const n = G.num_players || NM.length;
98+
for(let p=0;p<n;p++){
99+
const s = (f.scores && (f.scores[p] != null ? f.scores[p] : f.scores[String(p)])) || 0;
100+
const pct = total ? (100*s/total).toFixed(1) : '0.0';
101+
rows += `<div class="team"><div class="tname">`
102+
+ `<span class="sw" style="background:${col(p)}"></span>`
103+
+ `${NM[p] || ('player'+(p+1))}</div>`
104+
+ `<div class="stat"><span>tiles</span><b>${s}</b></div>`
105+
+ `<div class="stat"><span>share</span><b>${pct}%</b></div></div>`;
106+
}
107+
const res = done ? (G.draw ? 'TIE' : (G.winner || '\\u2014')) : '';
108+
return rows
109+
+ `<div class="stat"><span>tick</span><b>${f.turn} / ${G.max_ticks||''}</b></div>`
110+
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
111+
}
112+
return {setup, draw, side};
113+
})();
114+
"""
115+
116+
117+
class PaintVolleyReplayer(ReplayRenderer):
118+
arena = "PaintVolley"
119+
sim_glob = "sim_*.json"
120+
DRAW_JS = DRAW_JS
121+
122+
def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None:
123+
"""Cheap per-sim winner for the index (reads the top-level ``winner`` without
124+
touching the frames). ``winner`` is a 0-based player id, or null for a draw."""
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)
134+
135+
def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
136+
log = json.loads(raw.decode(errors="replace"))
137+
cols = log.get("cols", 32)
138+
rows = log.get("rows", 24)
139+
n = log.get("num_players", 2)
140+
141+
# Prefer the tournament's real player names; fall back to what the engine recorded.
142+
names = list(log.get("names", []))
143+
if players:
144+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
145+
while len(names) < n:
146+
names.append(f"player{len(names) + 1}")
147+
148+
frames = [
149+
{
150+
"turn": fr.get("tick", idx),
151+
"grid": fr["grid"],
152+
"balls": fr.get("balls", []),
153+
"players": fr.get("players", []),
154+
"scores": fr.get("scores", {}),
155+
}
156+
for idx, fr in enumerate(log.get("frames", []))
157+
]
158+
159+
win = log.get("winner")
160+
draw = win is None
161+
winner = None if draw else names[win] if 0 <= win < len(names) else str(win)
162+
163+
return ReplayData(
164+
w=cols,
165+
h=rows,
166+
frames=frames,
167+
winner=winner,
168+
draw=draw,
169+
extra={"names": names, "num_players": n, "max_ticks": log.get("max_ticks")},
170+
)

codeclash/replay/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ def get_replayer(arena: str) -> ReplayRenderer | None:
5757
from codeclash.arenas.gomoku.replay import GomokuReplayer
5858

5959
return GomokuReplayer()
60+
if arena == "PaintVolley":
61+
from codeclash.arenas.paintvolley.replay import PaintVolleyReplayer
62+
63+
return PaintVolleyReplayer()
6064
if arena == "Halite":
6165
from codeclash.arenas.halite.replay import HaliteReplayer
6266

@@ -102,6 +106,20 @@ def _arena_from_name(folder: Path) -> str:
102106
return parts[1] if len(parts) > 1 else ""
103107

104108

109+
def _fill_sim_winners(games: list[GameRef], renderer: ReplayRenderer | None, players: list[dict]) -> None:
110+
"""Populate each game's per-sim winner via the renderer's cheap ``peek_winner``.
111+
Skipped entirely for arenas that don't implement it (avoids extra sim I/O)."""
112+
if renderer is None or type(renderer).peek_winner is ReplayRenderer.peek_winner:
113+
return
114+
for g in games:
115+
try:
116+
res = renderer.peek_winner(read_sim(g), players)
117+
except Exception:
118+
res = None
119+
if res is not None:
120+
g.sim_winner, g.sim_draw = res
121+
122+
105123
def load_tournament(folder: Path) -> TournamentInfo:
106124
"""Read a tournament folder: metadata (if present) plus every discoverable game.
107125
@@ -139,6 +157,7 @@ def load_tournament(folder: Path) -> TournamentInfo:
139157
arena = _arena_from_name(folder)
140158
renderer = get_replayer(arena)
141159
games = discover_games(folder, renderer.sim_glob) if renderer else []
160+
_fill_sim_winners(games, renderer, [])
142161
return TournamentInfo(
143162
folder=folder, arena=arena, players=[], rounds=None, sims_per_round=None, round_winners={}, games=games
144163
)
@@ -158,6 +177,7 @@ def load_tournament(folder: Path) -> TournamentInfo:
158177
games = discover_games(folder, renderer.sim_glob) if renderer else []
159178
for g in games: # attach each game's round winner for the index
160179
g.winner = round_winners.get(g.round)
180+
_fill_sim_winners(games, renderer, players)
161181

162182
return TournamentInfo(
163183
folder=folder,

0 commit comments

Comments
 (0)