Skip to main content
🌍 Simulation GuideUpdated June 2026

Gazebo Harmonic Tutorial 2026World SDF · Robot Model · ROS 2 Bridge

Complete 2026 guide to Gazebo Harmonic (gz-harmonic) — the recommended simulator for ROS 2 Jazzy on Ubuntu 24.04. Covers install, custom world SDF creation, differential-drive robot with lidar and IMU sensors, ros_gz_bridge YAML config, Python launch file, plugin reference, and troubleshooting.

gz simSDFros_gz_bridgeDiffDrive plugingpu_lidar

Gazebo Version Matrix 2026

Gazebo VersionCodenameROS 2 PairingUbuntuSupport Until
Gz 9 ← use thisHarmonicJazzy (LTS)24.042028
Gz 8 GardenHumble/Iron22.042026
Gz 7 FortressHumble22.042025 EOL

Install Gazebo Harmonic

Install Gazebo Harmonic

Gazebo Harmonic is the recommended version for ROS 2 Jazzy (Ubuntu 24.04).

bash
# Add Gazebo repo and install Harmonic
sudo apt update
sudo apt install -y curl lsb-release gnupg

sudo curl https://packages.osrfoundation.org/gazebo.gpg \
  --output /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] \
  http://packages.osrfoundation.org/gazebo/ubuntu-stable $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/gazebo-stable.list

sudo apt update
sudo apt install -y gz-harmonic

# Verify
gz sim --version

Install ROS 2 ↔ Gazebo Bridge

ros_gz_bridge connects ROS 2 topics to Gazebo transports bidirectionally.

bash
sudo apt install -y \
  ros-jazzy-ros-gz \
  ros-jazzy-ros-gz-bridge \
  ros-jazzy-ros-gz-sim \
  ros-jazzy-ros-gz-interfaces

# Test: start Gazebo empty world, bridge /clock
source /opt/ros/jazzy/setup.bash
gz sim -r empty.sdf &
ros2 run ros_gz_bridge parameter_bridge /clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock

ros_gz replaces the old ros_ign_* packages. Use ros-jazzy-ros-gz for Harmonic.

Create a World SDF

Create a Basic World SDF

SDF (Simulation Description Format) defines worlds, models, and plugins in Gazebo.

xml
<?xml version="1.0" ?>
<sdf version="1.10">
  <world name="my_world">

    <!-- Physics engine -->
    <physics name="1ms" type="ignored">
      <max_step_size>0.001</max_step_size>
      <real_time_factor>1.0</real_time_factor>
    </physics>

    <!-- Plugins required for Gazebo Harmonic -->
    <plugin filename="gz-sim-physics-system"
            name="gz::sim::systems::Physics"/>
    <plugin filename="gz-sim-sensors-system"
            name="gz::sim::systems::Sensors">
      <render_engine>ogre2</render_engine>
    </plugin>
    <plugin filename="gz-sim-scene-broadcaster-system"
            name="gz::sim::systems::SceneBroadcaster"/>
    <plugin filename="gz-sim-user-commands-system"
            name="gz::sim::systems::UserCommands"/>

    <!-- Lighting -->
    <light name="sun" type="directional">
      <cast_shadows>true</cast_shadows>
      <pose>0 0 10 0 0 0</pose>
      <diffuse>0.8 0.8 0.8 1</diffuse>
      <specular>0.2 0.2 0.2 1</specular>
      <direction>-0.5 0.1 -0.9</direction>
    </light>

    <!-- Ground plane -->
    <model name="ground_plane">
      <static>true</static>
      <link name="link">
        <collision name="collision">
          <geometry><plane><normal>0 0 1</normal></plane></geometry>
        </collision>
        <visual name="visual">
          <geometry><plane><normal>0 0 1</normal><size>100 100</size></plane></geometry>
          <material>
            <ambient>0.8 0.8 0.8 1</ambient>
            <diffuse>0.8 0.8 0.8 1</diffuse>
          </material>
        </visual>
      </link>
    </model>

    <!-- Include an obstacle box -->
    <model name="box">
      <pose>3 0 0.5 0 0 0</pose>
      <link name="link">
        <collision name="collision">
          <geometry><box><size>1 1 1</size></box></geometry>
        </collision>
        <visual name="visual">
          <geometry><box><size>1 1 1</size></box></geometry>
          <material><ambient>1 0 0 1</ambient></material>
        </visual>
      </link>
    </model>

  </world>
</sdf>

Save as my_world.sdf. Launch with: gz sim my_world.sdf -r

Robot Model with Sensors

A complete differential-drive robot SDF with wheel joints, IMU, 2D lidar, and DiffDrive plugin — ready to receive /cmd_vel.

Differential-Drive Robot Model

A minimal two-wheeled robot with DiffDrive plugin, IMU sensor, and lidar.

xml
<?xml version="1.0" ?>
<sdf version="1.10">
  <model name="diff_bot">
    <pose>0 0 0.16 0 0 0</pose>

    <!-- Base link -->
    <link name="base_link">
      <inertial>
        <mass>5.0</mass>
        <inertia><ixx>0.1</ixx><iyy>0.1</iyy><izz>0.1</izz></inertia>
      </inertial>
      <visual name="base_visual">
        <geometry><box><size>0.4 0.3 0.1</size></box></geometry>
        <material><ambient>0 0.5 1 1</ambient></material>
      </visual>
      <collision name="base_collision">
        <geometry><box><size>0.4 0.3 0.1</size></box></geometry>
      </collision>

      <!-- IMU sensor -->
      <sensor name="imu" type="imu">
        <always_on>true</always_on>
        <update_rate>100</update_rate>
        <topic>imu</topic>
      </sensor>

      <!-- 2D Lidar -->
      <sensor name="lidar" type="gpu_lidar">
        <pose>0.2 0 0.1 0 0 0</pose>
        <topic>scan</topic>
        <update_rate>10</update_rate>
        <always_on>true</always_on>
        <lidar>
          <scan>
            <horizontal>
              <samples>640</samples>
              <resolution>1</resolution>
              <min_angle>-3.14159</min_angle>
              <max_angle>3.14159</max_angle>
            </horizontal>
          </scan>
          <range>
            <min>0.12</min>
            <max>10.0</max>
            <resolution>0.015</resolution>
          </range>
        </lidar>
      </sensor>
    </link>

    <!-- Left wheel -->
    <link name="left_wheel">
      <pose>0 0.175 -0.06 1.5708 0 0</pose>
      <inertial><mass>1.0</mass></inertial>
      <visual name="visual"><geometry><cylinder><radius>0.1</radius><length>0.04</length></cylinder></geometry></visual>
      <collision name="collision"><geometry><cylinder><radius>0.1</radius><length>0.04</length></cylinder></geometry></collision>
    </link>

    <joint name="left_wheel_joint" type="revolute">
      <parent>base_link</parent><child>left_wheel</child>
      <axis><xyz>0 0 1</xyz></axis>
    </joint>

    <!-- Right wheel (mirror of left) -->
    <link name="right_wheel">
      <pose>0 -0.175 -0.06 1.5708 0 0</pose>
      <inertial><mass>1.0</mass></inertial>
      <visual name="visual"><geometry><cylinder><radius>0.1</radius><length>0.04</length></cylinder></geometry></visual>
      <collision name="collision"><geometry><cylinder><radius>0.1</radius><length>0.04</length></cylinder></geometry></collision>
    </link>

    <joint name="right_wheel_joint" type="revolute">
      <parent>base_link</parent><child>right_wheel</child>
      <axis><xyz>0 0 1</xyz></axis>
    </joint>

    <!-- DiffDrive plugin -->
    <plugin filename="gz-sim-diff-drive-system"
            name="gz::sim::systems::DiffDrive">
      <left_joint>left_wheel_joint</left_joint>
      <right_joint>right_wheel_joint</right_joint>
      <wheel_separation>0.35</wheel_separation>
      <wheel_radius>0.1</wheel_radius>
      <odom_publish_frequency>50</odom_publish_frequency>
      <topic>cmd_vel</topic>
      <odom_topic>odom</odom_topic>
      <frame_id>odom</frame_id>
      <child_frame_id>base_link</child_frame_id>
    </plugin>

    <!-- Joint State publisher -->
    <plugin filename="gz-sim-joint-state-publisher-system"
            name="gz::sim::systems::JointStatePublisher"/>

  </model>
</sdf>

gpu_lidar requires a GPU. Use lidar (CPU) type if running in headless/CI environments.

ROS 2 Bridge & Launch File

ROS 2 Bridge Config (YAML)

Bridge Gazebo topics to ROS 2 topics using a config file for complex setups.

yaml
# bridge_config.yaml
# Format: gz_topic@ros_type[direction]gz_type
# [  = gz → ros (subscribe from gz)
# ]  = ros → gz (publish to gz)
# @  = bidirectional

- ros_topic_name: /cmd_vel
  gz_topic_name: /cmd_vel
  ros_type_name: geometry_msgs/msg/Twist
  gz_type_name: gz.msgs.Twist
  direction: ROS_TO_GZ

- ros_topic_name: /odom
  gz_topic_name: /odom
  ros_type_name: nav_msgs/msg/Odometry
  gz_type_name: gz.msgs.Odometry
  direction: GZ_TO_ROS

- ros_topic_name: /scan
  gz_topic_name: /scan
  ros_type_name: sensor_msgs/msg/LaserScan
  gz_type_name: gz.msgs.LaserScan
  direction: GZ_TO_ROS

- ros_topic_name: /imu
  gz_topic_name: /imu
  ros_type_name: sensor_msgs/msg/Imu
  gz_type_name: gz.msgs.IMU
  direction: GZ_TO_ROS

- ros_topic_name: /clock
  gz_topic_name: /clock
  ros_type_name: rosgraph_msgs/msg/Clock
  gz_type_name: gz.msgs.Clock
  direction: GZ_TO_ROS

Launch Bridge + Simulation

Python launch file starting Gazebo, spawning the robot, and running the bridge.

python
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, ExecuteProcess, TimerAction
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node


def generate_launch_description():
    pkg = get_package_share_directory("my_robot_sim")

    # Start Gazebo Harmonic with our world
    gz_sim = ExecuteProcess(
        cmd=["gz", "sim", "-r",
             os.path.join(pkg, "worlds", "my_world.sdf")],
        output="screen",
    )

    # Spawn robot model into running simulation
    spawn_robot = TimerAction(
        period=3.0,   # wait for Gazebo to start
        actions=[
            ExecuteProcess(
                cmd=["gz", "service", "-s", "/world/my_world/create",
                     "--reqtype", "gz.msgs.EntityFactory",
                     "--reptype", "gz.msgs.Boolean",
                     "--timeout", "1000",
                     "--req",
                     f'sdf_filename: "{os.path.join(pkg, "models", "diff_bot.sdf")}", name: "diff_bot"'],
                output="screen",
            )
        ]
    )

    # ros_gz_bridge
    bridge = Node(
        package="ros_gz_bridge",
        executable="parameter_bridge",
        arguments=["--ros-args", "-p",
                   f"config_file:={os.path.join(pkg, 'config', 'bridge_config.yaml')}"],
        output="screen",
    )

    # robot_state_publisher (reads URDF for TF)
    rsp = Node(
        package="robot_state_publisher",
        executable="robot_state_publisher",
        parameters=[{
            "robot_description":
                open(os.path.join(pkg, "urdf", "diff_bot.urdf")).read()
        }],
    )

    return LaunchDescription([gz_sim, spawn_robot, bridge, rsp])

Always add a TimerAction delay before spawning to ensure Gazebo is ready.

Plugin Reference

PluginfilenameUse Case
DiffDrivegz-sim-diff-drive-systemDifferential-drive mobile robot
JointTrajectoryControllergz-sim-joint-trajectory-controller-systemRobot arm joint trajectory
Physicsgz-sim-physics-systemDART physics engine (required)
Sensorsgz-sim-sensors-systemCamera, lidar, IMU, GPS
SceneBroadcastergz-sim-scene-broadcaster-systemRender scene to GUI (required)
RosGzBridge (external)ros_gz_bridgeBridge Gz ↔ ROS 2 topics
PosePublishergz-sim-pose-publisher-systemPublish model poses
Thrustergz-sim-thruster-systemMarine/underwater propulsion

Common Issues & Fixes

Problemgz sim: command not found
FixSource Gazebo env: `source /usr/share/gz/gz-harmonic/setup.sh`. Add to ~/.bashrc.
ProblemBridge exits: 'No topic advertisers'
FixStart bridge AFTER Gazebo is running (use TimerAction ≥3s). Check topic names match exactly.
ProblemLiDAR outputs empty ranges
Fixgpu_lidar needs GPU. Switch to <type>lidar</type> for CPU or run with `--render-engine ogre`.
ProblemRobot spawns underground
FixAdd base offset to model <pose>. Adjust z to wheel_radius above ground.
Problemuse_sim_time not working
FixBridge /clock before starting Nav2. Set use_sim_time:=true on ALL nodes including robot_state_publisher.
ProblemInertia warning: near-zero mass
FixSet <mass> > 0 and diagonal <inertia> values > 0.001 on every link.

Related Guides