Skip to content
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ ENV PORT 8880
EXPOSE 8880

# Define the command to run your app using CMD which defines your runtime
CMD ["sh", "-c", "python rose/main.py --listen 0.0.0.0 --track ${TRACK} --port ${PORT}"]
CMD ["sh", "-c", "python main.py --listen 0.0.0.0 --track ${TRACK} --port ${PORT}"]
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,41 @@ The response in `JSON` format should include the car name and the recommended ac
}
}
```

## Batch simulation (headless automation)

`simulate.py` runs many full games back-to-back between two drivers, without a
websocket/UI and without the live game's rate throttle, then writes aggregated
win/loss/tie stats to a JSON file. This is the same HTTP driver contract used by
the live engine (see `Testing your driver` above), so it works against any real
`rose-game-ai` driver process, community driver, or your own `mydriver.py`.

```bash
# Start two drivers first, e.g.:
# (cd ../rose-game-ai && python main.py --driver mydriver.py --port 8081)
# (cd ../rose-game-ai && python main.py --driver examples/driver.py --port 8082)

python simulate.py \
--drivers http://127.0.0.1:8081 http://127.0.0.1:8082 \
--games 50 \
--track random \
--output batch_stats.json
```

`batch_stats.json` contains per-driver `wins`/`losses`/`ties`/`avg_score`, plus a
`per_game` breakdown:

```json
{
"games": 50,
"track_type": "random",
"drivers": ["http://127.0.0.1:8081", "http://127.0.0.1:8082"],
"results": {
"DriverA": {"wins": 27, "losses": 21, "ties": 2, "avg_score": 612.4},
"DriverB": {"wins": 21, "losses": 27, "ties": 2, "avg_score": 588.9}
},
"per_game": [{"scores": {"DriverA": 620, "DriverB": 590}, "winner": "DriverA"}]
}
```

Run `python simulate.py --help` for all options.
77 changes: 64 additions & 13 deletions rose/engine/logic.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import logging
import random
import time
import aiohttp

from rose.engine import config
Expand All @@ -14,10 +15,17 @@


async def initialize_game(state):
"""Reset game settings and return re-initialized track and players."""
"""Reset game settings and return re-initialized track and players.

Does NOT touch state["running"] — the game loop only polls the reset
flag periodically, so clobbering running here would silently drop a
Run request issued shortly after Reset, before this had a chance to run.
Callers that want reset to also stop the game must set running=0
themselves, atomically with the reset request.
"""
state["reset"] = None
state["running"] = 0
state["timeleft"] = config.game_duration
state["game_started_at"] = time.monotonic()
track = initialize_track(state["track_type"] != "same")
players = await initialize_players(state["drivers"])
return track, players
Expand Down Expand Up @@ -74,13 +82,23 @@ async def initialize_players(drivers):
return players


async def game_loop(state, active_websockets):
def determine_winner(players):
"""Return the name of the highest-scoring player, or None if tied/empty."""
if not players:
return None
best_score = max(player.score for player in players)
leaders = [player.name for player in players if player.score == best_score]
return leaders[0] if len(leaders) == 1 else None


async def game_loop(state, active_websockets, telemetry=None):
"""
Asynchronously execute the game loop, using provided state and active websockets.

Args:
state (dict): Dictionary containing game state data (rate, running status, time left, etc.).
active_websockets (set): A set of active websocket connections for communication.
telemetry (TelemetryObserver, optional): observer notified on each tick. Defaults to None.

Returns:
None
Expand All @@ -103,7 +121,7 @@ async def game_loop(state, active_websockets):
if state["running"] == 1:
# Start executing a step in the game
task = asyncio.create_task(
game_step(state, players, track, active_websockets)
game_step(state, players, track, active_websockets, telemetry)
)

# Pause the game loop for a specified duration, based on the rate defined in the state
Expand All @@ -121,7 +139,33 @@ async def game_loop(state, active_websockets):
await asyncio.sleep(1)


async def game_step(state, players, track, active_websockets):
async def play_tick(players, track, telemetry=None, step_index=None):
"""
Execute a single tick of game logic: fetch drivers' actions, advance the
track, and score the resulting player actions. Shared by the live
websocket-driven game loop and headless batch simulation.

Args:
players (list): List of Player objects.
track (Track): the game track.
telemetry (TelemetryObserver, optional): observer notified after scoring.
step_index (int, optional): tick counter passed through to the observer.
"""

# Fetch players actions using an asynchronous HTTP session
await net.fetch_drivers_actions(players, track.matrix())

# Update track
track.update()

# Process the actions of the players
score.process(players, track)

if telemetry is not None:
telemetry.on_step(step_index, players, track)


async def game_step(state, players, track, active_websockets, telemetry=None):
"""
Execute a game step: Update the track, fetch drivers' actions, process actions, and update websockets.

Expand All @@ -130,24 +174,31 @@ async def game_step(state, players, track, active_websockets):
players (list): List of Player objects.
track (Track): the game track.
active_websockets (Any): Active websockets for communication (assuming a suitable data structure).
telemetry (TelemetryObserver, optional): observer notified after scoring.
"""

try:
# Fetch players actions using an asynchronous HTTP session
await net.fetch_drivers_actions(players, track.matrix())

# Update track
track.update()

# Process the actions of the players
score.process(players, track)
await play_tick(
players, track, telemetry, config.game_duration - state["timeleft"]
)

# Send data to all WebSocket connections
await net.update_websockets(True, state, players, track, active_websockets)

# Progress the game's timer
state["timeleft"] -= 1

if telemetry is not None and state["timeleft"] < 1:
result = {
"scores": {player.name: player.score for player in players},
"winner": determine_winner(players),
"players": {player.name: player.state() for player in players},
"duration_seconds": round(
time.monotonic() - state["game_started_at"], 2
),
}
telemetry.on_game_end(players, result)

except asyncio.CancelledError:
log.info("Game step was canceled!")
raise
9 changes: 9 additions & 0 deletions rose/engine/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def __init__(self, name, car, lane):
self.pickups = None
self.misses = None
self.hits = None
self.wall_hits = None
self.water_hits = None
self.crack_hits = None
self.breaks = None
self.jumps = None
self.collisions = None
Expand All @@ -50,6 +53,9 @@ def reset(self):
self.pickups = 0
self.misses = 0
self.hits = 0
self.wall_hits = 0
self.water_hits = 0
self.crack_hits = 0
self.breaks = 0
self.collisions = 0
self.jumps = 0
Expand Down Expand Up @@ -81,6 +87,9 @@ def state(self):
"pickups": self.pickups,
"misses": self.misses,
"hits": self.hits,
"wall_hits": self.wall_hits,
"water_hits": self.water_hits,
"crack_hits": self.crack_hits,
"breaks": self.breaks,
"jumps": self.jumps,
"collisions": self.collisions,
Expand Down
4 changes: 4 additions & 0 deletions rose/engine/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def process(players, track):
player.y += 1
player.score += config.score_move_backward
player.hits += 1
player.wall_hits += 1

log.debug(
"player %s hit %s: lost %d points, moved back to %d,%d",
Expand Down Expand Up @@ -99,6 +100,7 @@ def process(players, track):
player.y += 1
player.score += config.score_move_backward
player.hits += 1
player.crack_hits += 1

log.debug(
"player %s hit %s: lost %d points, moved back to %d,%d",
Expand Down Expand Up @@ -128,6 +130,7 @@ def process(players, track):
player.y += 1
player.score += config.score_move_backward
player.hits += 1
player.water_hits += 1

log.debug(
"player %s hit %s: lost %d points, moved back to %d,%d",
Expand Down Expand Up @@ -155,6 +158,7 @@ def process(players, track):
else:
# Move forward leaving the obstacle on the track
player.score += config.score_move_forward
player.misses += 1

log.debug("player %s missed %s", player.name, obstacle)

Expand Down
Loading