Skip to content

Commit bdd79f4

Browse files
committed
Single player mode initial version
1 parent 09ab73d commit bdd79f4

9 files changed

Lines changed: 301 additions & 33 deletions

File tree

codeclash/agents/abstract.py

Lines changed: 85 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import uuid
23
from abc import ABC, abstractmethod
34

45
from dotenv import load_dotenv
@@ -18,9 +19,11 @@ def __init__(
1819
config: dict,
1920
environment: Environment,
2021
game_context: GameContext,
21-
):
22+
) -> None:
2223
self.config = config
2324
self.name = config["name"]
25+
self._player_unique_id = uuid.uuid4()
26+
"""Unique ID that doesn't clash even accross multiple games. Used for git tags."""
2427
self.environment = environment
2528
self.game_context = game_context
2629
self.game_context.render_and_set_prompts()
@@ -29,28 +32,39 @@ def __init__(
2932
log_path=self.game_context.log_local / f"{self.name}.log",
3033
emoji="👤",
3134
)
35+
self._metadata = {
36+
"name": self.name,
37+
"player_unique_id": self._player_unique_id,
38+
"diff": {}, # mapping round -> diff
39+
"incremental_diff": {}, # mapping round -> diff
40+
}
3241

33-
@property
34-
def branch_name(self):
35-
"""Get the branch name for the agent's codebase."""
36-
return f"{self.game_context.id}.{self.name}"
37-
38-
def commit(self):
39-
"""Commit changes to the agent's codebase."""
40-
r, rounds = self.game_context.round, self.game_context.rounds
41-
for cmd in [
42-
"git add -A",
43-
f"git commit --allow-empty -m 'Round {r}/{rounds} Update'",
44-
]:
45-
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
46-
self.logger.info(f"Committed changes for {self.name} for round {r}/{rounds}")
42+
# --- Main methods ---
4743

48-
def on_round_update(self, new_round: int):
49-
"""Update the agent's round to match the game round."""
44+
def pre_run_hook(self, *, new_round: int) -> None:
45+
"""Should be called before we call the run method."""
46+
if new_round == 1:
47+
self._tag_round(0)
5048
self.game_context.round = new_round
5149
self.game_context.render_and_set_prompts()
5250

53-
def push(self):
51+
def post_run_hook(self, *, round: int) -> None:
52+
"""Should be called after we called the run method."""
53+
self._commit()
54+
self._metadata["diff"][round] = self._get_round_diff(round)
55+
self._metadata["incremental_diff"][round] = self._get_round_diff(
56+
round, incremental=True
57+
)
58+
59+
@abstractmethod
60+
def run(self) -> None:
61+
"""Given the observation / recap, update the codebase"""
62+
63+
def get_metadata(self) -> dict:
64+
"""Get metadata for the agent."""
65+
return self._metadata
66+
67+
def push(self) -> None:
5468
"""Push codebase to a branch on the game's remote repository."""
5569
token = os.getenv("GITHUB_TOKEN")
5670
if not token:
@@ -59,13 +73,61 @@ def push(self):
5973
for cmd in [
6074
"git remote remove origin",
6175
f"git remote add origin https://x-access-token:{token}@github.com/{GH_ORG}/{self.game_context.name}.git",
62-
f"git push origin {self.branch_name}",
76+
f"git push origin {self._branch_name}",
77+
"git push origin --tags",
6378
]:
6479
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
6580
self.logger.info(
66-
f"Pushed {self.name} commit history to remote repository (branch {self.branch_name})"
81+
f"Pushed {self.name} commit history to remote repository (branch {self._branch_name})"
6782
)
6883

69-
@abstractmethod
70-
def run(self):
71-
"""Given the observation / recap, update the codebase"""
84+
# --- Helper methods ---
85+
86+
def _tag_round(self, round: int) -> None:
87+
"""Git tag the codebase at the given round."""
88+
assert_zero_exit_code(
89+
self.environment.execute(
90+
f"git tag -a {self._get_round_tag_name(round)} -m 'Round {round} Update'"
91+
),
92+
logger=self.logger,
93+
)
94+
95+
@property
96+
def _branch_name(self) -> str:
97+
"""Get the branch name for the agent's codebase."""
98+
return f"{self.game_context.id}.{self.name}"
99+
100+
def _get_round_tag_name(self, round: int) -> str:
101+
"""Get git tag name for the version of the codebase at the given round."""
102+
return f"{self._player_unique_id}-round-{round}"
103+
104+
def _commit(self) -> None:
105+
"""Commit changes to the agent's codebase."""
106+
r = self.game_context.round
107+
for cmd in [
108+
"git add -A",
109+
f"git commit --allow-empty -m 'Round {r} Update'",
110+
]:
111+
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
112+
self._tag_round(r)
113+
self.logger.info(f"Committed changes for {self.name} for round {r}")
114+
115+
def _get_round_diff(self, round: int, *, incremental: bool = False) -> str:
116+
"""Get the diff between the round and initial version (round 0).
117+
If incremental is True, get the diff between the round and the previous round.
118+
Returns empty string if round is 0.
119+
"""
120+
if round == 0:
121+
return ""
122+
if incremental:
123+
previous_round_tag = self._get_round_tag_name(round - 1)
124+
else:
125+
previous_round_tag = self._get_round_tag_name(0)
126+
current_round_tag = self._get_round_tag_name(round)
127+
out = assert_zero_exit_code(
128+
self.environment.execute(
129+
f"git diff {previous_round_tag}..{current_round_tag}"
130+
),
131+
logger=self.logger,
132+
)
133+
return out["output"]

codeclash/agents/dummy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ class Dummy(Player):
55
"""A dummy player that does nothing. Mainly for testing purposes."""
66

77
def run(self):
8-
self.commit()
8+
pass
9+
# self.commit() # now called in post_round_hook

codeclash/agents/minisweagent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,4 @@ def run(self):
110110
traj_path,
111111
self.game_context.log_env / traj_path.name,
112112
)
113-
self.commit()
113+
# self.commit() # now called in post_round_hook

codeclash/games/abstract.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(self, config: dict):
4141
self.name, log_path=self.log_local / "game.log", emoji="🏓"
4242
)
4343
self.environment: DockerEnvironment = self.get_environment()
44-
assert len(config["players"]) >= 2, "At least two players are required"
44+
# assert len(config["players"]) >= 2, "At least two players are required"
4545

4646
@property
4747
def image_name(self) -> str:
@@ -124,9 +124,6 @@ def _pre_round_setup(self, agents: list[Player]):
124124
"""Copy agent codebases into game's container and make round log file"""
125125
self.round += 1
126126
# Notify agents of round update
127-
for agent in agents:
128-
if hasattr(agent, "on_round_update"):
129-
agent.on_round_update(self.round)
130127
self.logger.info(f"▶️ Running {self.name} round {self.round}...")
131128

132129
# Copy agent codebases into game's container

codeclash/utils/environment.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,23 @@ def copy_file_from_container(
113113
f"Failed to copy {container.container_id}:{src_path} to {dest_path}: {result.stdout}{result.stderr}"
114114
)
115115
return result
116+
117+
118+
def create_file_on_container(
119+
container: DockerEnvironment,
120+
*,
121+
content: str,
122+
dest_path: str | Path,
123+
):
124+
"""
125+
Create a file with given content on a Docker container.
126+
Uses a temporary file on the local filesystem for the transfer.
127+
"""
128+
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp_file:
129+
tmp_file.write(content)
130+
tmp_file_path = Path(tmp_file.name)
131+
132+
try:
133+
copy_file_to_container(container, tmp_file_path, dest_path)
134+
finally:
135+
tmp_file_path.unlink() # Clean up the temporary file
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
game:
2+
name: BattleSnake
3+
rounds: 5
4+
args:
5+
width: 11
6+
height: 11
7+
browser: false
8+
player:
9+
agent: mini
10+
config: configs/mini/default.yaml
11+
model: openai/gpt-5-mini
12+
name: main
13+
prompts:
14+
game_description: |
15+
You are a software developer ({{player_id}}) competing in a coding game called BattleSnake.
16+
Your bot (`main.py`) controls a snake on a grid-based board.
17+
Snakes collect food, avoid collisions, and try to outlast their opponents.
18+
19+
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}}.
20+
After you and your competitor finish editing your codebases, the game is run automatically.
21+
22+
Your task: improve the bot in `main.py`, located in {{working_dir}}.
23+
{{working_dir}} is your codebase, which contains both your bot and supporting assets.

configs/mini/default.yaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
agent:
22
system_template: |
3-
You are a helpful assistant editing a codebase to play a programming game.
3+
You are a helpful assistant interacting continuously with a computer by submitting commands.
4+
You'll be editing a codebase to play a programming game.
5+
6+
<important>
7+
This is an interactive process where you will think and issue ONE command, see its result, then think and issue your next command.
8+
</important>
49
510
Your response must contain exactly ONE bash code block with ONE command (or commands connected with && or ||).
611
Include a THOUGHT section before your command where you explain your reasoning process.
@@ -142,8 +147,8 @@ agent:
142147
143148
Note: In rare cases, if you need to reference a similar format in your command, you might have
144149
to proceed in two steps, first writing TRIPLEBACKTICKSBASH, then replacing them with ```bash.
145-
step_limit: 0.
146-
cost_limit: 0.
150+
step_limit: 30
151+
cost_limit: 1.
147152
environment:
148153
env:
149154
PAGER: cat

main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ def main(config_path: str, cleanup: bool = False, push_agent: bool = False):
1717
agents.append(get_agent(agent_conf, config["prompts"], game))
1818

1919
try:
20-
for _ in range(game.rounds):
20+
for round in range(1, game.rounds + 1):
2121
game.run_round(agents)
2222
for agent in agents:
23+
agent.pre_run_hook(new_round=round)
2324
agent.run()
25+
agent.post_run_hook(round=round)
2426
finally:
2527
game.end(cleanup)
2628
if push_agent:

0 commit comments

Comments
 (0)