Skip to content

Commit cc164ae

Browse files
authored
Add Bomberland arena (#105)
* Add Bomberland arena * Document Bomberland smoke checks * Handle Bomberland validation timeouts
1 parent 80ff2c1 commit cc164ae

14 files changed

Lines changed: 1261 additions & 1 deletion

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ The winner is the LM agent who wins the most rounds.
9898
## 🧩 Available Arenas
9999

100100
CodeClash includes competitive programming games and simulation-backed arenas, including BattleSnake,
101-
CoreWar, CybORG, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
101+
Bomberland, CoreWar, CybORG, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
102+
103+
Bomberland is a Bomberman-style grid arena based on Coder One's Bomberland competition. Agents edit
104+
a Python `bomberland_agent.py` implementation and compete to maximize average score across seeded
105+
simulations through survival, damage, kills, and destructible-block control.
102106

103107
SCML is a supply-chain negotiation arena based on the ANAC Supply Chain Management League OneShot
104108
track. Agents edit a Python `scml_agent.py` implementation and compete to maximize average profit

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from codeclash.arenas.battlecode24.battlecode24 import BattleCode24Arena
44
from codeclash.arenas.battlecode25.battlecode25 import BattleCode25Arena
55
from codeclash.arenas.battlesnake.battlesnake import BattleSnakeArena
6+
from codeclash.arenas.bomberland.bomberland import BomberlandArena
67
from codeclash.arenas.bridge.bridge import BridgeArena
78
from codeclash.arenas.chess.chess import ChessArena
89
from codeclash.arenas.corewar.corewar import CoreWarArena
@@ -23,6 +24,7 @@
2324
BattleCode24Arena,
2425
BattleCode25Arena,
2526
BattleSnakeArena,
27+
BomberlandArena,
2628
BridgeArena,
2729
ChessArena,
2830
CoreWarArena,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.11-slim-bookworm
2+
3+
ARG BOMBERLAND_COMMIT=8b6b7a1c013d96feb0a5468a7a59a63a7c59dadc
4+
ENV BOMBERLAND_UPSTREAM_COMMIT=${BOMBERLAND_COMMIT}
5+
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends git ca-certificates \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
# Keep a pinned copy of the upstream competition source for provenance and
11+
# agent authors who want to inspect the original starter-kit shape.
12+
RUN git clone https://github.com/CoderOneHQ/bomberland.git /opt/bomberland \
13+
&& cd /opt/bomberland \
14+
&& git checkout ${BOMBERLAND_COMMIT}
15+
16+
WORKDIR /workspace
17+
COPY codeclash/arenas/bomberland/runtime/ /workspace/
18+
19+
RUN git init \
20+
&& git config user.email "arena@codeclash.com" \
21+
&& git config user.name "CodeClash Arena" \
22+
&& git add . \
23+
&& git commit -m "Initialize Bomberland runtime"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from codeclash.arenas.bomberland.bomberland import BomberlandArena
2+
3+
__all__ = ["BomberlandArena"]
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import json
2+
import shlex
3+
import subprocess
4+
5+
from codeclash.agents.player import Player
6+
from codeclash.arenas.arena import CodeArena, RoundStats
7+
from codeclash.constants import RESULT_TIE
8+
from codeclash.utils.environment import assert_zero_exit_code
9+
10+
RESULTS_JSON = "bomberland_results.json"
11+
CRASH_SCORE = -1_000_000.0
12+
13+
14+
class BomberlandArena(CodeArena):
15+
name: str = "Bomberland"
16+
submission: str = "bomberland_agent.py"
17+
description: str = """Bomberland is a Bomberman-style multi-agent arena based on Coder One's Bomberland competition.
18+
19+
Your bot is a Python file named `bomberland_agent.py` that defines a callable named `next_actions`.
20+
The callable receives a game-state dictionary and should return a dictionary mapping unit ids to actions:
21+
22+
def next_actions(game_state):
23+
return {"unit_0": "up"}
24+
25+
Valid actions are `up`, `down`, `left`, `right`, `bomb`, and `stay`. Each round runs several
26+
deterministic seeded games. Your units move on a destructible grid, place bombs, destroy blocks,
27+
damage opposing units, and score by survival, damage, kills, and block destruction.
28+
"""
29+
default_args: dict = {
30+
"sims_per_round": 4,
31+
"ticks": 80,
32+
"width": 11,
33+
"height": 11,
34+
"unit_count": 3,
35+
"agent_timeout": 0.25,
36+
"validation_timeout": 5,
37+
"timeout": 180,
38+
}
39+
40+
def __init__(self, config: dict, **kwargs):
41+
player_count = len(config.get("players", []))
42+
if player_count != 2:
43+
raise ValueError("Bomberland requires exactly two players")
44+
game_config = config.get("game", {})
45+
game_args = game_config.get("args", {})
46+
sims_per_round = int(
47+
game_args.get("sims_per_round", game_config.get("sims_per_round", self.default_args["sims_per_round"]))
48+
)
49+
if sims_per_round % 2 != 0:
50+
raise ValueError("Bomberland requires an even sims_per_round so both players get paired starting sides")
51+
super().__init__(config, **kwargs)
52+
53+
def _game_arg(self, key: str):
54+
nested_args = self.game_config.get("args", {})
55+
return nested_args.get(key, self.game_config.get(key, self.default_args[key]))
56+
57+
def _sims_per_round(self) -> int:
58+
return int(self._game_arg("sims_per_round"))
59+
60+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
61+
quoted_submission = shlex.quote(self.submission)
62+
file_check = agent.environment.execute(f"test -f {quoted_submission} && echo exists")
63+
if "exists" not in file_check["output"]:
64+
return False, f"Submission file `{self.submission}` not found in the workspace root"
65+
66+
content = agent.environment.execute(f"cat {quoted_submission}")["output"]
67+
if not content.strip():
68+
return False, f"`{self.submission}` is empty"
69+
70+
syntax_check = agent.environment.execute(f"python -m py_compile {quoted_submission}")
71+
if syntax_check["returncode"] != 0:
72+
return False, f"Python syntax error in `{self.submission}`:\n{syntax_check['output']}"
73+
74+
validation_timeout = int(self._game_arg("validation_timeout"))
75+
try:
76+
import_check = agent.environment.execute(
77+
"python - <<'PY'\n"
78+
"import importlib.util\n"
79+
f"spec = importlib.util.spec_from_file_location('submission_agent', {self.submission!r})\n"
80+
"module = importlib.util.module_from_spec(spec)\n"
81+
"spec.loader.exec_module(module)\n"
82+
"assert hasattr(module, 'next_actions'), 'next_actions callable not found'\n"
83+
"assert callable(module.next_actions), 'next_actions must be callable'\n"
84+
"state = {\n"
85+
" 'connection': {'agent_id': 'Alice'},\n"
86+
" 'agents': {'Alice': {'unit_ids': ['u0']}},\n"
87+
" 'unit_state': {'u0': {'agent_id': 'Alice', 'hp': 3, 'coordinates': [1, 1]}},\n"
88+
" 'entities': [],\n"
89+
" 'world': {'width': 5, 'height': 5},\n"
90+
" 'tick': 0,\n"
91+
"}\n"
92+
"result = module.next_actions(state)\n"
93+
"assert result is None or isinstance(result, dict), 'next_actions must return a dict or None'\n"
94+
"PY",
95+
timeout=validation_timeout,
96+
)
97+
except subprocess.TimeoutExpired:
98+
return False, f"`next_actions` validation exceeded {validation_timeout}s timeout"
99+
if import_check["returncode"] != 0:
100+
return False, f"Could not import or call `next_actions` from `{self.submission}`:\n{import_check['output']}"
101+
102+
return True, None
103+
104+
def execute_round(self, agents: list[Player]) -> None:
105+
agent_args = []
106+
for agent in agents:
107+
agent_args.extend(["--agent", f"{agent.name}=/{agent.name}/{self.submission}"])
108+
109+
cmd = [
110+
"python",
111+
"run_bomberland.py",
112+
"--sims",
113+
str(self._sims_per_round()),
114+
"--ticks",
115+
str(self._game_arg("ticks")),
116+
"--width",
117+
str(self._game_arg("width")),
118+
"--height",
119+
str(self._game_arg("height")),
120+
"--unit-count",
121+
str(self._game_arg("unit_count")),
122+
"--agent-timeout",
123+
str(self._game_arg("agent_timeout")),
124+
"--output",
125+
str(self.log_env / RESULTS_JSON),
126+
*agent_args,
127+
]
128+
full_cmd = " ".join(shlex.quote(part) for part in cmd)
129+
self.logger.info(f"Running game: {full_cmd}")
130+
try:
131+
response = self.environment.execute(full_cmd, timeout=int(self._game_arg("timeout")))
132+
except subprocess.TimeoutExpired as exc:
133+
raise RuntimeError("Bomberland round timed out") from exc
134+
assert_zero_exit_code(response, logger=self.logger)
135+
136+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
137+
result_file = self.log_round(round_num) / RESULTS_JSON
138+
if not result_file.exists():
139+
self.logger.error(f"Missing result file: {result_file}")
140+
stats.winner = RESULT_TIE
141+
for agent in agents:
142+
stats.scores[agent.name] = CRASH_SCORE
143+
stats.player_stats[agent.name].score = CRASH_SCORE
144+
stats.details.append(
145+
json.dumps(
146+
{
147+
"player": agent.name,
148+
"score": CRASH_SCORE,
149+
"status": "error",
150+
"error": f"missing Bomberland result file: {result_file}",
151+
},
152+
sort_keys=True,
153+
)
154+
)
155+
return
156+
157+
with open(result_file) as f:
158+
result = json.load(f)
159+
160+
scores = {agent.name: CRASH_SCORE for agent in agents}
161+
for player, score in result.get("average_scores", {}).items():
162+
if player in scores:
163+
scores[player] = float(score)
164+
165+
stats.scores = scores
166+
stats.details = result.get("details", [])
167+
for player, score in scores.items():
168+
stats.player_stats[player].score = score
169+
170+
if not scores:
171+
stats.winner = RESULT_TIE
172+
return
173+
174+
top_score = max(scores.values())
175+
winners = [player for player, score in scores.items() if score == top_score]
176+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
bomberland_results.json
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Bomberland CodeClash Runtime
2+
3+
This runtime adapts the Coder One Bomberland competition format into a compact,
4+
deterministic CodeClash arena. The Docker image keeps a pinned checkout of
5+
`CoderOneHQ/bomberland` at `/opt/bomberland` for provenance and starter-kit
6+
reference, while `run_bomberland.py` provides the runtime used by CodeClash.
7+
8+
Submissions must provide `bomberland_agent.py` with:
9+
10+
```python
11+
def next_actions(game_state):
12+
return {"unit_0": "up"}
13+
```
14+
15+
Valid string actions are `up`, `down`, `left`, `right`, `bomb`, and `stay`.
16+
The game-state dictionary follows the upstream starter-kit shape where possible:
17+
`connection.agent_id` identifies the player, `agents[player].unit_ids` lists the
18+
controlled units, `unit_state` contains unit coordinates and health, and
19+
`entities` contains walls, destructible blocks, bombs, and blast tiles.
20+
21+
Round simulation counts must be even so each player receives both starting sides.
22+
23+
Smoke command from the repository root:
24+
25+
```bash
26+
uv run python main.py configs/examples/Bomberland__dummy__r1__s2.yaml -o /tmp/codeclash-bomberland-smoke
27+
```
28+
29+
Use a fresh `-o` directory when rerunning the smoke check. Expected output:
30+
the command exits with status 0, both players pass validation, each round
31+
summary contains floating-point scores, and the output directory contains
32+
`metadata.json`, `game.log`, `tournament.log`, and compressed round logs.
33+
34+
Expected result shape:
35+
36+
```json
37+
{
38+
"average_scores": {"player_a": 330.0, "player_b": 330.0},
39+
"total_scores": {"player_a": 660.0, "player_b": 660.0},
40+
"sims": 2,
41+
"details": ["... per-simulation JSON strings ..."]
42+
}
43+
```
44+
45+
Each detail entry is a JSON string with `scores`, `stats`, `alive_units`,
46+
`alive_hp`, `ticks`, and `winner` fields. Per-player `stats` include
47+
`agent_errors` and `invalid_actions`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def next_actions(game_state):
2+
agent_id = game_state["connection"]["agent_id"]
3+
unit_ids = game_state["agents"].get(agent_id, {}).get("unit_ids", [])
4+
unit_state = game_state.get("unit_state", {})
5+
return {unit_id: "stay" for unit_id in unit_ids if unit_state.get(unit_id, {}).get("hp", 0) > 0}

0 commit comments

Comments
 (0)