DEV Community

Cover image for Building a Four-Wheeled Mobile Robot in ROS 2 — From Talker/Listener to a Robot You Can Drive
Abdullateef OGUNDIPE
Abdullateef OGUNDIPE

Posted on

Building a Four-Wheeled Mobile Robot in ROS 2 — From Talker/Listener to a Robot You Can Drive

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 at x = ±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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The four-wheeled AMR robot rendered in RViz
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'),
    ])
Enter fullscreen mode Exit fullscreen mode

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_bridge carries 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])
Enter fullscreen mode Exit fullscreen mode

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 AMR robot spawned in the Gazebo world
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: position and velocity — 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

Verify both controllers are running:

ros2 control list_controllers
# diff_drive_controller   ... active
# joint_state_broadcaster ... active
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The AMR robot driving in Gazebo

Driving the robot around Gazebo with keyboard teleop.

Jazzy note: ROS 2 Jazzy's diff_drive_controller requires the command message to be TwistStamped (a velocity plus a timestamp/frame header) — plain Twist was removed. Hence the stamped:=true flag. 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}}}"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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_urdf can 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-install to avoid stale-file traps.
  • Plugin and version details matter. The Gazebo plugin needs its full library name (libgz_ros2_control-system.so), and Jazzy forces TwistStamped commands.
  • 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)