A warehouse robotics simulation built on ROS 2 Jazzy and Gazebo. An autonomous mobile robot (AMR) navigates a 10x10 meter warehouse, picking up and delivering items between colored zones to score points. The system is designed as a testbed for comparing navigation strategies -- from simple heuristics to LLM-driven policies -- with full episode logging for analysis.
+------------------------------+
| WAREHOUSE |
| |
| [PICKUP_B] [PICKUP_A]|
| (-3, 3) (3, 3) |
| [obstacles] |
| |
| [START/ROBOT] |
| (0, 0) |
| |
| [DROPOFF_2] [DROPOFF_1] |
| (-3,-3) [CHARGER] (3,-3)|
| (0,-3) |
+------------------------------+
- Setup
- Quick Start
- How It Works
- Architecture
- Launch Files
- Configuration
- Custom Messages
- Policy Adapters
- Visualization
- Logging
- Testing
- Project Structure
- Ubuntu 24.04
- ROS 2 Jazzy
- Python 3.12
- colcon
# Gazebo simulation + bridge packages
sudo apt install ros-jazzy-ros-gz ros-jazzy-ros-gz-bridge ros-jazzy-ros-gz-sim
# Optional: for external LLM policy
pip install openaicd ~/Scarif_AMR
source /opt/ros/jazzy/setup.bash
colcon build
source install/setup.bashYou must run source install/setup.bash in every new terminal before using any ros2 commands with this workspace.
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 launch amr_game_bringup play_heuristic.launch.pyThis starts Gazebo (headless), spawns the robot, runs the game loop with a heuristic navigator, opens RViz, and begins logging to ~/.amr_game_logs/.
To run without the RViz window:
ros2 launch amr_game_bringup play_heuristic.launch.py viz:=falseThe system runs a continuous sense-plan-act loop:
1 Hz
Observation -----> Policy -----> ActionCommand
^ |
| v
Executor <---- /odom, /lidar Executor
publishes feedback receives action
observation executes turn+drive
^ |
| v
Game Master Result (bool)
checks zones, success/failure
awards points |
| v
+------- Logger writes JSONL step record
- Executor publishes an
ObservationStateafter each action (or on startup). - Policy node reads the observation and computes an
ActionCommand-- a turn angle, forward distance, and speed. - Executor receives the command and executes it in two phases:
- Turn phase: P-controller rotates to the target heading (tolerance: 0.05 rad).
- Drive phase: P-controller drives the target distance (tolerance: 0.05 m).
- If lidar detects an obstacle within 0.3 m ahead, the executor stops and reports failure.
- If the robot is stuck for >3 seconds, a recovery maneuver backs up 0.3 m and turns 45 degrees.
- Game master monitors the robot's position against zone boundaries (0.5 m radius). When the robot reaches the pickup zone, the task transitions to the dropoff phase. Reaching the dropoff zone awards points and generates a new task.
- Logger records each completed step (observation, action, result) as a JSONL line.
Each task is a pickup-dropoff pair. The game master randomly selects one of two pickup zones and one of two dropoff zones. Each completed delivery awards 10 points. Tasks time out after 60 seconds if not completed. An episode ends after 10 tasks or 300 seconds, whichever comes first.
Battery starts at 100% and drains at 0.5% per meter traveled. When the robot enters the CHARGER zone (0, -3), battery recharges at 5% per second. If battery reaches 0%, the episode ends. The heuristic policy automatically routes to the charger when battery drops below 20%.
The workspace contains 7 ROS 2 packages:
| Package | Build Type | Description |
|---|---|---|
amr_game_msgs |
ament_cmake | Custom .msg definitions shared by all nodes |
amr_game_description |
ament_cmake | Robot SDF model, warehouse world SDF, Gazebo-ROS bridge config |
amr_action_executor |
ament_python | Closed-loop motion executor with P-controllers and safety checks |
amr_game_master |
ament_python | Game logic: task generation, zone detection, scoring, battery, episodes |
amr_llm_policy |
ament_python | Policy node with pluggable adapters (heuristic, mock LLM, external LLM) |
amr_game_logger |
ament_python | Writes each step to a JSONL log file |
amr_game_bringup |
ament_python | Launch files and configuration that tie everything together |
Build order is enforced by dependencies: amr_game_msgs builds first (messages), then the rest in parallel.
| Node | Package | Subscribes | Publishes |
|---|---|---|---|
game_master_node |
amr_game_master | /odom |
/game_state, /zone_markers |
executor_node |
amr_action_executor | /policy/action, /odom, /lidar, /game_state |
/cmd_vel, /executor/result, /observation |
policy_node |
amr_llm_policy | /observation, /executor/result |
/policy/action |
logger_node |
amr_game_logger | /observation, /policy/action, /executor/result, /game_state |
-- |
| Topic | Type | Direction | Description |
|---|---|---|---|
/cmd_vel |
geometry_msgs/Twist |
ROS -> Gazebo | Velocity commands to the robot |
/odom |
nav_msgs/Odometry |
Gazebo -> ROS | Robot position and velocity |
/lidar |
sensor_msgs/LaserScan |
Gazebo -> ROS | 360-sample lidar scan |
/imu |
sensor_msgs/Imu |
Gazebo -> ROS | Inertial measurement unit |
/clock |
rosgraph_msgs/Clock |
Gazebo -> ROS | Simulation time |
/tf |
tf2_msgs/TFMessage |
Gazebo -> ROS | Coordinate frame transforms |
/game_state |
amr_game_msgs/GameState |
Game master | Score, battery, current task, episode status |
/observation |
amr_game_msgs/ObservationState |
Executor | Full observation vector for the policy |
/policy/action |
amr_game_msgs/ActionCommand |
Policy | Commanded turn, drive distance, speed |
/executor/result |
std_msgs/Bool |
Executor | True if action succeeded, false if blocked |
/zone_markers |
visualization_msgs/MarkerArray |
Game master | Zone cylinders and labels for RViz |
| Service | Type | Description |
|---|---|---|
/reset_episode |
std_srvs/Empty |
Reset score, battery, task, and timer |
ros2 service call /reset_episode std_srvs/srv/EmptyThe SDF model is a differential-drive robot with:
- Chassis: 30x20x10 cm box, 5 kg
- Drive wheels: 2 wheels (4 cm radius), 24 cm separation
- Caster: Frictionless rear ball for stability
- Lidar: 360-sample GPU lidar on top (8 m max range, 10 Hz)
- IMU: 50 Hz inertial sensor
The DiffDrive plugin accepts Twist messages on /cmd_vel and publishes odometry on /odom.
A 10x10 m enclosed space with:
- 4 perimeter walls
- 4 box obstacles at various positions
- 6 colored ground zones (visible in Gazebo GUI and as RViz markers)
All launch files are in amr_game_bringup:
| Launch File | What It Starts |
|---|---|
sim.launch.py |
Gazebo + robot spawn + ROS-GZ bridge |
game.launch.py |
Game master + executor + logger |
play_heuristic.launch.py |
sim + game + heuristic policy + RViz |
play_mock_llm.launch.py |
sim + game + mock LLM policy + RViz |
play_external_llm.launch.py |
sim + game + external LLM policy + RViz |
All play_*.launch.py files:
| Argument | Default | Description |
|---|---|---|
viz |
true |
Open RViz with preconfigured view |
headless |
true |
Run Gazebo in server-only mode (no Gazebo GUI) |
play_external_llm.launch.py additionally:
| Argument | Default | Description |
|---|---|---|
openai_api_key |
'' |
API key (or set OPENAI_API_KEY env var) |
openai_model |
gpt-4o-mini |
Which OpenAI model to query |
# Heuristic with RViz (default)
ros2 launch amr_game_bringup play_heuristic.launch.py
# Heuristic, no visualization
ros2 launch amr_game_bringup play_heuristic.launch.py viz:=false
# Heuristic with Gazebo GUI (needs display)
ros2 launch amr_game_bringup play_heuristic.launch.py headless:=false
# External LLM
ros2 launch amr_game_bringup play_external_llm.launch.py openai_api_key:=sk-...
# Run sim and game separately
ros2 launch amr_game_bringup sim.launch.py # terminal 1
ros2 launch amr_game_bringup game.launch.py # terminal 2All tunable parameters live in src/amr_game_bringup/config/game_params.yaml and are loaded by every node via the launch files.
| Parameter | Default | Description |
|---|---|---|
task_points |
10 | Points per completed delivery |
task_time_limit |
60.0 s | Time before a task expires |
max_tasks_per_episode |
10 | Episode ends after this many tasks |
episode_timeout |
300.0 s | Episode hard time limit |
battery_drain_per_meter |
0.5 | Battery % lost per meter traveled |
battery_charge_rate |
5.0 | Battery % gained per second at charger |
| Parameter | Default | Description |
|---|---|---|
turn_kp |
2.0 | Proportional gain for yaw controller |
drive_kp |
1.0 | Proportional gain for distance controller |
turn_tolerance |
0.05 rad | Acceptable yaw error (~3 degrees) |
drive_tolerance |
0.05 m | Acceptable position error |
obstacle_distance |
0.3 m | Lidar stop threshold |
stuck_timeout |
3.0 s | Time before recovery triggers |
recovery_backup |
0.3 m | How far to reverse during recovery |
recovery_turn |
0.785 rad | How much to turn during recovery (45 deg) |
control_rate |
20.0 Hz | Executor inner loop frequency |
| Parameter | Default | Description |
|---|---|---|
policy_rate |
1.0 Hz | How often the policy runs |
Actions are clamped to safe ranges before being sent to the executor:
| Dimension | Range |
|---|---|
d_forward_meters |
[-2.0, 2.0] |
d_turn_radians |
[-pi, pi] |
speed_mps |
[0.0, 1.0] |
Defined in amr_game_msgs:
float64 d_forward_meters # Distance to drive (negative = reverse)
float64 d_turn_radians # Angle to turn before driving
float64 speed_mps # Movement speed
string reason # Human-readable explanation
string task_id # Unique identifier (8-char hex)
string pickup_zone # e.g. "PICKUP_A"
string dropoff_zone # e.g. "DROPOFF_1"
float64 time_remaining # Seconds until timeout
int32 points # Reward for completion
int32 score # Cumulative episode score
float64 battery_pct # 0-100
TaskInfo current_task
TaskInfo[] pending_tasks
bool episode_active
The full observation vector consumed by policy adapters:
geometry_msgs/Pose pose # Robot position + orientation
geometry_msgs/Twist velocity # Current linear + angular velocity
float64 battery # Battery percentage
int32 score # Current score
TaskInfo task # Active task details
float64[36] lidar_histogram # Min range per 10-degree sector
ActionCommand last_action # Previous action
bool last_action_succeeded
int32 step_number
ObservationState observation
ActionCommand action
float64 reward_delta
bool collision
builtin_interfaces/Time timestamp
The policy_node uses a pluggable adapter pattern. Set the policy_type parameter to select which adapter runs.
A handwritten navigation strategy:
- Compute
atan2heading to the current goal zone. - Turn toward the goal, then drive forward (up to 2 m per step).
- If lidar detects an obstacle ahead, reduce step size and turn to avoid.
- If battery < 20%, override the goal to CHARGER.
This is deterministic and requires no external services.
Returns a fixed action every step: drive 0.5 m forward at 0.3 m/s with no turning. Useful for testing the pipeline without needing real decision-making.
Calls the OpenAI API each step:
- Formats the
ObservationStateinto a structured prompt (seeprompt_template.py). - Sends the prompt to the configured model with
temperature=0.2. - Parses the JSON response into an
ActionCommand. - Falls back to a safe stop if the response can't be parsed.
The prompt includes the robot's position, battery, task, lidar histogram, and a description of all zone positions. The LLM is instructed to respond with a JSON object containing d_forward_meters, d_turn_radians, speed_mps, and reason.
Requires pip install openai and an API key.
Create a Python module with a compute_action(obs: ObservationState) -> ActionCommand function and place it in amr_llm_policy/adapters/. Then add an import branch in policy_node.py for your new policy_type string.
RViz launches automatically with all play_*.launch.py files (disable with viz:=false).
The preconfigured RViz view shows:
- Top-down orthographic camera covering the full warehouse
- Grid at 1 m spacing
- LaserScan (red dots) from
/lidar - Odometry arrows (green) showing the robot's path from
/odom - TF frames for the robot links
- Zone markers: colored cylinders at each zone with text labels, plus a white ring around the current goal
| Zone | Position | Color | Role |
|---|---|---|---|
| START | (0, 0) | Green | Robot spawn point |
| PICKUP_A | (3, 3) | Yellow | Pickup location |
| PICKUP_B | (-3, 3) | Orange | Pickup location |
| DROPOFF_1 | (3, -3) | Blue | Delivery location |
| DROPOFF_2 | (-3, -3) | Purple | Delivery location |
| CHARGER | (0, -3) | Cyan | Battery recharge station |
Every completed action step is written to a JSONL file in ~/.amr_game_logs/. A new file is created for each episode.
Filename format: episode_YYYYMMDD_HHMMSS.jsonl
Record format (one JSON object per line):
{
"step": 1,
"timestamp": 1770433460.14,
"observation": {
"pose": {"x": 1.44, "y": 1.32, "z": 0.0},
"velocity": {"linear_x": 0.05, "linear_y": 0.0, "angular_z": 0.0},
"battery": 99.06,
"score": 0,
"task": {
"task_id": "dcecdfdd",
"pickup_zone": "PICKUP_A",
"dropoff_zone": "DROPOFF_2",
"time_remaining": 58.0,
"points": 10
},
"lidar_histogram": [5.1, 5.18, ...],
"last_action_succeeded": true
},
"action": {
"d_forward_meters": 2.0,
"d_turn_radians": 0.785,
"speed_mps": 0.3,
"reason": "heading to pickup PICKUP_A"
},
"result": {
"collision": false,
"succeeded": true
},
"game_state": {
"score": 0,
"battery_pct": 99.06,
"episode_active": true,
"current_task": {
"task_id": "dcecdfdd",
"pickup_zone": "PICKUP_A",
"dropoff_zone": "DROPOFF_2"
}
}
}import json
with open("~/.amr_game_logs/episode_20260206_220406.jsonl") as f:
steps = [json.loads(line) for line in f]
# Collision rate
collisions = sum(1 for s in steps if s["result"]["collision"])
print(f"{collisions}/{len(steps)} steps had collisions")
# Final score
print(f"Final score: {steps[-1]['game_state']['score']}")source /opt/ros/jazzy/setup.bash
colcon build
source install/setup.bash
colcon test
colcon test-result --verboseThe test suite covers:
| Test File | Package | What It Tests |
|---|---|---|
test_lidar_utils.py |
amr_action_executor | Lidar binning, forward clearance checks, edge cases |
test_executor_logic.py |
amr_action_executor | Quaternion-to-yaw extraction, angle normalization |
test_action_clamping.py |
amr_llm_policy | Action value clamping at all boundaries |
test_schema.py |
amr_game_logger | JSONL record validation |
test_zones.py |
amr_game_master | Zone definitions, coordinate integrity |
Scarif_AMR/
src/
amr_game_msgs/ # Custom message definitions
msg/
ActionCommand.msg
TaskInfo.msg
GameState.msg
ObservationState.msg
EpisodeStep.msg
CMakeLists.txt
package.xml
amr_game_description/ # Robot + world models
models/amr_robot/
model.sdf # Diff-drive robot with lidar + IMU
worlds/
warehouse.sdf # 10x10m warehouse with zones + obstacles
config/
bridge.yaml # ROS-Gazebo topic bridge mapping
launch/
spawn.launch.py # Gazebo + spawn + bridge
amr_action_executor/ # Motion execution
amr_action_executor/
executor_node.py # P-controller turn/drive with safety
lidar_utils.py # Histogram binning, clearance checks
test/
test_lidar_utils.py
test_executor_logic.py
amr_game_master/ # Game logic
amr_game_master/
game_master_node.py # Tasks, scoring, battery, zones, markers
zones.py # Zone position constants
test/
test_zones.py
amr_llm_policy/ # Policy adapters
amr_llm_policy/
policy_node.py # Adapter dispatcher, action clamping
prompt_template.py # Observation -> LLM prompt formatter
adapters/
heuristic_policy.py # atan2 navigation
mock_llm_policy.py # Fixed safe action
external_llm_policy.py # OpenAI API integration
test/
test_action_clamping.py
amr_game_logger/ # Episode logging
amr_game_logger/
logger_node.py # JSONL writer
schema.py # Message-to-dict conversion, validation
test/
test_schema.py
amr_game_bringup/ # Launch orchestration
launch/
sim.launch.py
game.launch.py
play_heuristic.launch.py
play_mock_llm.launch.py
play_external_llm.launch.py
config/
game_params.yaml # All tunable parameters
amr_game.rviz # RViz display configuration