This is a step-by-step guide documenting how I built a four-wheeled mobile robot in ROS 2 Jazzy: modelling it in URDF, simulating it in Gazebo, and driving it with the ros2_control stack. I started explaining from the most basic ROS idea, two programs talking to each other, before going to the build up of a robot rolling across a simulated world.
Environment: ROS 2 Jazzy · Gazebo Harmonic · Ubuntu 24.04
How ROS 2 programs talk: the Talker and the Listener
Before any robot, you need to understand the one idea the whole of ROS 2 is built on: independent programs passing messages to each other. Everything else, robots, sensors, controllers, is just this idea repeated.
The radio station analogy
Imagine a radio station. It broadcasts music on a frequency, say 98.5 FM. The station doesn't know who is listening. It doesn't have a list of your name. It just broadcasts. Anyone with a radio tuned to 98.5 hears it. One listener or a million, the station behaves exactly the same.
ROS 2 works precisely like this:
| Radio world | ROS 2 world |
|---|---|
| Radio station broadcasting | Publisher (a "talker" node) |
| The frequency, 98.5 FM | Topic (a named channel) |
| The song being played | Message (the data) |
| Your radio, tuned to 98.5 | Subscriber (a "listener" node) |
The key insight and the thing that makes ROS scalable is that the talker and the listener never need to know about each other. They only agree on two things: the topic name (the frequency) and the message type (what kind of content). This is called publish/subscribe communication, and it's anonymous and decoupled. You can start the listener before or after the talker; you can add ten more listeners; nothing breaks.
Basic terms (glossary)
- ROS 2 — Robot Operating System 2. Not an operating system; a framework/toolkit for building robot software out of small communicating programs.
- Node — a single program that does one job (read a sensor, drive wheels, etc.).
-
Topic — a named channel that nodes publish to or subscribe from (like
/cmd_vel). -
Message — the structured data sent over a topic (e.g. a
String, a velocity). - Publisher — a node that sends messages on a topic (the talker).
- Subscriber — a node that receives messages on a topic (the listener).
- Package — a folder bundling related nodes, config, and launch files.
-
Workspace — a folder holding one or more packages, built with
colcon. - colcon — the build tool that compiles/installs a workspace.
The Talker (publisher)
This node publishes a text message on the topic topic every half-second:
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class MinimalPublisher(Node):
def __init__(self):
super().__init__('minimal_publisher')
# Create a publisher: message type String, topic name 'topic', queue size 10
self.publisher_ = self.create_publisher(String, 'topic', 10)
# Fire a callback every 0.5 seconds
self.timer = self.create_timer(0.5, self.timer_callback)
self.i = 0
def timer_callback(self):
msg = String()
msg.data = 'Hello World: %d' % self.i
self.publisher_.publish(msg) # broadcast
self.get_logger().info('Publishing: "%s"' % msg.data)
self.i += 1
def main(args=None):
rclpy.init(args=args)
node = MinimalPublisher()
rclpy.spin(node) # keep the node alive, processing callbacks
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
What matters here: the node creates a publisher bound to a topic name and a message type, then broadcasts on a timer. It never references any listener. It's the radio station transmitting into the air.
The Listener (subscriber)
This node subscribes to the same topic and reacts every time a message arrives:
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class MinimalSubscriber(Node):
def __init__(self):
super().__init__('minimal_subscriber')
# Subscribe to the SAME topic name and message type as the talker
self.subscription = self.create_subscription(
String, 'topic', self.listener_callback, 10)
def listener_callback(self, msg):
# Runs automatically whenever a message arrives on 'topic'
self.get_logger().info('I heard: "%s"' % msg.data)
def main(args=None):
rclpy.init(args=args)
node = MinimalSubscriber()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
What matters here: the subscriber's callback fires on its own whenever a message lands. It's the radio, tuned to the frequency, playing whatever comes through. The match between talker and listener is purely the topic name (topic) and the message type (String).
Running them
In two separate terminals (each sourced with source install/setup.bash):
# Terminal 1 — the talker
ros2 run py_pubsub talker
# Terminal 2 — the listener
ros2 run py_pubsub listener
The talker prints Publishing: "Hello World: 0", 1, 2… and the listener prints
I heard: "Hello World: 0", 1, 2…. You can inspect the channel live:
ros2 topic list # see 'topic' in the list
ros2 topic echo /topic # watch the raw messages fly by
Why this matters for the robot: later, driving the robot is the exact same pattern. The keyboard is a talker publishing velocity commands on
/cmd_vel; the robot's controller is a listener tuned to/cmd_vel. Master talker/listener and you've already understood how the robot is driven.
🎥 Talker & Listener explained
A walkthrough of the talker/listener code and a live demo of the two nodes communicating, explained with the radio-station analogy.
Part 1 — The robot's body: writing a URDF
A robot in ROS is described by a URDF (Unified Robot Description Format), an XML file that defines the robot as a tree of rigid parts.
- Link — a rigid body (the chassis, a wheel).
Joint — the connection between two links, defining how they move relative to each other.
Each link can carry three descriptions of itself:<visual>— how it looks (used by RViz).<collision>— the shape the physics engine uses for contact. Skip it and the robot falls through the floor.-
<inertial>— mass and how that mass is distributed (the inertia tensor). Skip it and the robot explodes the instant Gazebo's physics touches it.The root frame: base_footprint
The tree starts with a massless base_footprint — a point on the ground used as a reference — joined to the chassis by a fixed joint (a rigid weld, zero movement):
<robot name="amr">
<link name="base_footprint"/>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
</joint>
The chassis
The chassis is a box with all three descriptions. The inertia numbers come from the standard solid-box formula, I = (1/12)·m·(a² + b²) for each axis pair:
<link name="base_link">
<visual>
<geometry><box size="0.5 0.3 0.1"/></geometry>
<material name="blue"><color rgba="0 0 1 1"/></material>
</visual>
<collision>
<geometry><box size="0.5 0.3 0.1"/></geometry>
</collision>
<inertial>
<mass value="5.0"/>
<inertia ixx="0.0417" ixy="0" ixz="0"
iyy="0.1083" iyz="0" izz="0.1417"/>
</inertial>
</link>
A wheel and its joint
Each wheel is a cylinder. The inertia comes from the solid-cylinder formulas
(izz = ½·m·r², ixx = iyy = 1/12·m·(3r² + h²)). This block is repeated for all four wheels — only the joint origin changes:
<link name="wheel_front_left">
<visual>
<geometry><cylinder radius="0.1" length="0.04"/></geometry>
<material name="black"><color rgba="0 0 0 1"/></material>
</visual>
<collision>
<geometry><cylinder radius="0.1" length="0.04"/></geometry>
</collision>
<inertial>
<mass value="0.5"/>
<inertia ixx="0.0013" ixy="0" ixz="0"
iyy="0.0013" iyz="0" izz="0.0025"/>
</inertial>
</link>
<joint name="joint_wheel_front_left" type="continuous">
<parent link="base_link"/>
<child link="wheel_front_left"/>
<origin xyz="0.2 0.15 0" rpy="1.5708 0 0"/>
<axis xyz="0 0 1"/>
</joint>
Three things to understand in that joint:
-
type="continuous"— rotates freely with no angle limit. That's what a wheel needs. (fixed= welded;revolute= rotates but with a min/max limit like an elbow.) -
origin xyz— where the wheel sits. The four wheels are atx = ±0.2,y = ±0.15. -
rpy="1.5708 0 0"— a 90° roll. A cylinder points up the Z axis by default; this rotation lays it flat so it looks and behaves like a wheel.
Adding the two rear wheels was simply duplicating the wheel link + joint with
x = -0.2 origins (wheel_back_left, wheel_back_right), turning the two-wheel starter into a four-wheel robot.
Verifying the model
check_urdf amr.urdf
A healthy result prints the tree:
root Link: base_footprint has 1 child(ren)
child(1): base_link
child(1): wheel_back_left
child(2): wheel_back_right
child(3): wheel_front_left
child(4): wheel_front_right

The robot model displayed in RViz — blue chassis with four black wheels.
Lesson learned: check_urdf only validates the XML tree — it does not check for collision or inertial. A model with no mass passes check_urdf happily and then explodes in Gazebo. Always add collision + inertial to every link.
Part 2 — Seeing the robot: RViz first, then Gazebo
Why RViz before Gazebo
RViz is a visualiser with no physics — it just draws the model. Gazebo is a physics simulator. Always check the model in RViz first: if the geometry is wrong, you see it cleanly without physics chaos confusing the picture.
The display launch file starts three nodes: robot_state_publisher (reads the URDF andbroadcasts the robot's transforms), joint_state_publisher_gui (sliders to spin each wheel by hand), and rviz2:
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
pkg_share = get_package_share_directory('amr_description')
urdf_path = os.path.join(pkg_share, 'urdf', 'amr.urdf')
with open(urdf_path, 'r') as f:
robot_description = f.read()
return LaunchDescription([
Node(package='robot_state_publisher', executable='robot_state_publisher',
output='screen',
parameters=[{'robot_description': robot_description}]),
Node(package='joint_state_publisher_gui', executable='joint_state_publisher_gui',
output='screen'),
Node(package='rviz2', executable='rviz2', output='screen'),
])
Run it, set Fixed Frame to base_footprint, Add → RobotModel (Description Topic/robot_description), and drag the sliders. Each wheel should spin cleanly about its axle. That clean spin proves the rpy and axis are correct — which matters enormously later, because a wrong axis makes the robot drive sideways in Gazebo.
Spawning in Gazebo — the bridge concept
The single most important thing to understand about Gazebo and ROS 2:
They are two separate programs that do not share messages by default. Gazebo speaks its own language (Gazebo Transport); ROS 2 speaks DDS. A translator called the
ros_gz_bridgecarries specific topics across. No bridge = no communication.
The Gazebo launch file does four things: start Gazebo, publish the URDF, spawn the robot from that published description, and bridge the clock so ROS and Gazebo share simulation time:
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, RegisterEventHandler
from launch.event_handlers import OnProcessExit
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node
def generate_launch_description():
pkg_share = get_package_share_directory('amr_description')
urdf_path = os.path.join(pkg_share, 'urdf', 'amr.urdf')
with open(urdf_path, 'r') as f:
robot_description = f.read()
pkg_ros_gz_sim = get_package_share_directory('ros_gz_sim')
# 1. Start Gazebo with an empty world
gazebo = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_ros_gz_sim, 'launch', 'gz_sim.launch.py')),
launch_arguments={'gz_args': 'empty.sdf -r'}.items())
# 2. Publish the URDF to /robot_description
robot_state_publisher = Node(
package='robot_state_publisher', executable='robot_state_publisher',
output='screen',
parameters=[{'robot_description': robot_description, 'use_sim_time': True}])
# 3. Spawn the robot into Gazebo from the /robot_description topic
spawn = Node(
package='ros_gz_sim', executable='create', output='screen',
arguments=['-topic', 'robot_description', '-name', 'amr', '-z', '0.2'])
# 4. Bridge Gazebo's clock into ROS (one-way: Gazebo -> ROS)
clock_bridge = Node(
package='ros_gz_bridge', executable='parameter_bridge',
arguments=['/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock'],
output='screen')
# (controller spawners added in Part 3)
return LaunchDescription([gazebo, robot_state_publisher, spawn, clock_bridge])
The bridge argument reads as topic @ ROS-type [ Gazebo-type. The [ sets the direction to Gazebo → ROS. Time has a single source of truth (Gazebo), so it flows one way; a two-way bridge would have both sides fighting over the clock.
The real test: when you launch this, the robot should appear, drop the 20 cm, and sit still on its wheels. A robot that rests quietly is Gazebo confirming your mass, inertia, and collision are all correct. If it explodes or sinks, the URDF physics are wrong — back to Part 1.

The robot spawned in Gazebo, resting stably on its wheels.
Part 3 — Making it move: ros2_control
The robot has no steering wheels, so it's a differential (skid-steer) drive: it turns like a tank, by running the left and right sides at different speeds. Both sides equal → straight. Right side faster → turns left. Opposite directions → spins in place.
Control is handled by the ros2_control framework, which has three layers:
| Layer | What it does | Where it lives |
|---|---|---|
| Hardware interface | Commands the actual motors — here, simulated by Gazebo |
<ros2_control> + <gazebo> plugin in the URDF |
| Controller manager | Loads and runs the controllers | Started by the Gazebo plugin |
| Controllers | Do the work (drive logic, state reporting) | Defined in controllers.yaml
|
The beauty of this design: on a real robot you swap only the hardware-interface layer — everything else stays identical.
Command interfaces vs state interfaces
Two concepts sit at the heart of ros2_control:
-
Command interface = what you send to a joint (the knob you turn). For the wheels:
velocity— "spin at this speed." -
State interface = what you read back from a joint (the gauge you read). For the wheels:
positionandvelocity— the actual values, as if from an encoder. They can differ: you command 5 rad/s, but if a wheel slips, the state velocity might be 2 rad/s. That gap is what odometry and feedback control care about.
Declaring the interfaces in the URDF
<ros2_control name="GazeboSimSystem" type="system">
<hardware>
<plugin>gz_ros2_control/GazeboSimSystem</plugin>
</hardware>
<joint name="joint_wheel_front_left">
<command_interface name="velocity"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<!-- ...repeated for the other three wheel joints... -->
</ros2_control>
<gazebo>
<plugin filename="libgz_ros2_control-system.so"
name="gz_ros2_control::GazeboSimROS2ControlPlugin">
<parameters>/absolute/path/to/config/controllers.yaml</parameters>
</plugin>
</gazebo>
</robot>
The <ros2_control> block declares the interfaces; the <gazebo> block runs the engine (libgz_ros2_control-system.so) that starts the controller manager inside Gazebo and feeds it the config file.
Configuring the controllers
controller_manager:
ros__parameters:
update_rate: 100
use_sim_time: true
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
diff_drive_controller:
type: diff_drive_controller/DiffDriveController
diff_drive_controller:
ros__parameters:
left_wheel_names: ["joint_wheel_front_left", "joint_wheel_back_left"]
right_wheel_names: ["joint_wheel_front_right", "joint_wheel_back_right"]
wheel_separation: 0.30 # distance between left & right wheels (y = ±0.15)
wheel_radius: 0.10 # from the cylinder geometry
publish_rate: 50.0
odom_frame_id: odom
base_frame_id: base_link
enable_odom_tf: true
The four wheels are grouped into a left pair and a right pair, so the controller drives each side as a unit. wheel_separation and wheel_radius are the geometry it uses to convert "drive forward, turn left" into individual wheel speeds — so they must match the URDF exactly.
Starting the controllers — order matters
Controllers can't start until the controller manager exists (which only happens once the robot spawns). The launch file enforces this with event handlers: start the broadcaster after the robot spawns, then the drive controller after the broadcaster:
joint_state_broadcaster_spawner = Node(
package='controller_manager', executable='spawner',
arguments=['joint_state_broadcaster'], output='screen')
diff_drive_spawner = Node(
package='controller_manager', executable='spawner',
arguments=['diff_drive_controller'], output='screen')
delay_jsb = RegisterEventHandler(
OnProcessExit(target_action=spawn,
on_exit=[joint_state_broadcaster_spawner]))
delay_ddc = RegisterEventHandler(
OnProcessExit(target_action=joint_state_broadcaster_spawner,
on_exit=[diff_drive_spawner]))
return LaunchDescription([
gazebo, robot_state_publisher, spawn, clock_bridge, delay_jsb, delay_ddc])
Verify both controllers are running:
ros2 control list_controllers
# diff_drive_controller ... active
# joint_state_broadcaster ... active
Driving it
Here the radio analogy from Part 0 comes full circle. The keyboard is a talker publishing velocity commands; the diff_drive_controller is a listener on /diff_drive_controller/cmd_vel. Neither knows about the other — they just share a topic.
ros2 run teleop_twist_keyboard teleop_twist_keyboard \
--ros-args -p stamped:=true -r /cmd_vel:=/diff_drive_controller/cmd_vel
Driving the robot around Gazebo with keyboard teleop.
Jazzy note: ROS 2 Jazzy's
diff_drive_controllerrequires the command message to beTwistStamped(a velocity plus a timestamp/frame header) — plainTwistwas removed. Hence thestamped:=trueflag. A single manual command looks like:
ros2 topic pub --once /diff_drive_controller/cmd_vel geometry_msgs/msg/TwistStamped \
"{header: {frame_id: base_link}, twist: {linear: {x: 0.5}, angular: {z: 0.0}}}"
linear.x is forward speed; angular.z is turn rate. The controller turns these into wheel speeds and publishes odometry back on /odom.
How it all connects
amr.urdf ──(robot_state_publisher)──► /robot_description ──(create)──► robot in Gazebo
│ │
│ <ros2_control> declares interfaces │
└──► <gazebo> plugin starts controller_manager ──► loads diff_drive_controller
│
keyboard ──► /diff_drive_controller/cmd_vel ──────────────┘──► wheel speeds ──► robot moves
(TwistStamped) └──► /odom (feedback)
One file, amr.urdf, is the single source of truth. RViz, Gazebo, and the controllers all read the same description. Then the next part puts that model into a physics world; followed by the part that attaches a controller to the same model. And underneath it all is the foundational idea: programs publishing and subscribing to named topics — the radio station and the radio, scaled up into a working robot.
Lessons from the build
-
A model that passes
check_urdfcan still be broken — that check ignores collision and inertia. Test physics by spawning in Gazebo. -
Always verify the installed file, not just the source. A big chunk of debugging time came from an unsaved URDF: the source looked right but the file on disk (which the build installs and Gazebo reads) was an old version. Build with
--symlink-installto avoid stale-file traps. -
Plugin and version details matter. The Gazebo plugin needs its full library name (
libgz_ros2_control-system.so), and Jazzy forcesTwistStampedcommands. - Skid-steer turning is inherently sluggish — four fixed wheels must scrub sideways to rotate. That's physics, not a bug.
Repository
The full package (URDF, launch files, config, README) lives here:
https://github.com/limitless-ao/amr_description_assignment
Documented as part of my Aurora Robotics training.
Top comments (0)