RC Robot Car Expert

Build a WiFi-controlled robot car with obstacle avoidance and phone control

🔗Goal

Build a robot car controlled from your phone over WiFi. The ESP32 runs a web server with an HTML/JavaScript joystick interface -- open it in any phone browser, no app needed. The car also has an autonomous obstacle avoidance mode: an ultrasonic sensor mounted on a servo sweeps left and right to find the clearest path, and the car navigates around obstacles on its own.

graph LR
    subgraph Robot Car
        ESP[ESP32] -->|PWM| MD[L298N Motor Driver]
        MD --> M1[Left Motor]
        MD --> M2[Right Motor]
        ESP -->|GPIO| SRV[Servo Motor]
        SRV --> US[HC-SR04 Sensor]
        ESP -->|WiFi| WEB[Web Server]
    end
    PHONE[Phone Browser] -->|WiFi| WEB
    BAT[18650 Battery Pack<br/>7.4V] --> MD
    MD -->|5V Regulator| ESP

The car has two modes that you can switch between from the phone interface:

  • Manual mode: Full joystick control with variable speed and direction
  • Autonomous mode: The car drives itself, avoiding obstacles by scanning with the ultrasonic sensor

🔗Prerequisites

ComponentQtyNotes
ESP32 dev board1ESP32-WROOM-32 DevKit
L298N motor driver module1Dual H-bridge, supports up to 36V/2A per channel
DC gearbox motors (3-6V)2Typically sold with wheels (TT motors)
Wheels2Matching the motors
Caster wheel1Ball caster or swivel wheel for the front/back
HC-SR04 ultrasonic sensor1For distance measurement
SG90 servo motor1For sweeping the ultrasonic sensor
18650 Li-ion batteries2In series for 7.4V (use protected cells)
2S 18650 battery holder1With leads
Robot car chassis12WD acrylic or 3D-printed platform
Toggle switch1Main power on/off
Jumper wires~20Various lengths
Small breadboard1Half-size, mounted on the chassis
Zip ties / standoffsSeveralFor mounting components

About the chassis: You can buy a ready-made 2WD robot car chassis kit for a few USD that includes the acrylic platform, motors, wheels, caster, and mounting hardware. This saves a lot of time. Alternatively, you can use any flat rigid surface (even thick cardboard for a prototype).

🔗Software

No additional Arduino libraries are needed. The code uses only libraries built into the ESP32 Arduino core:

  • WiFi.h -- WiFi connectivity
  • WebServer.h -- HTTP server
  • ESP32Servo.h -- Install via Library Manager (search "ESP32Servo" by Kevin Harrington)

🔗Power System Architecture

Power management is critical for a robot car. The motors and the ESP32 have very different power requirements:

graph TD
    BAT[18650 Battery Pack<br/>7.4V 2S] --> SW[Power Switch]
    SW --> L298N[L298N Motor Driver<br/>VIN: 7.4V]
    L298N -->|5V Regulator Output| ESP[ESP32 DevKit<br/>via VIN pin]
    L298N -->|Motor A Output| M1[Left Motor]
    L298N -->|Motor B Output| M2[Right Motor]
    ESP -->|3.3V| SERVO[Servo Motor<br/>Power from ESP 5V pin]
    ESP -->|3.3V / 5V| HC[HC-SR04]
ComponentVoltageCurrent
DC motors (x2)3-6V100-250 mA each (up to 1A stall)
ESP323.3V (via onboard regulator)~240 mA peak
Servo (SG90)4.8-6V100-250 mA
HC-SR045V~15 mA

The L298N module has a built-in 5V voltage regulator. When you supply 7.4V to the motor input (12V terminal), the L298N outputs 5V on its 5V pin. You can use this to power the ESP32 through its VIN pin (the ESP32's onboard regulator then steps it down to 3.3V).

Important: The L298N's 5V regulator can only supply about $500\,\text{mA}$. This is enough for the ESP32 and HC-SR04, but the servo should be powered from the ESP32's 5V pin (which comes from the same source). If the servo causes brownouts, consider powering it from the battery through a separate 5V buck converter.

Do not connect the battery directly to the ESP32 3.3V pin. The 7.4V will destroy it. Always go through the L298N's 5V regulator or a separate buck converter to the ESP32's VIN pin.

🔗L298N Internal Voltage Drop

The L298N is a BJT-based H-bridge, so it has a significant voltage drop of about $2\,\text{V}$. With a 7.4V battery, the motors will actually see approximately:

$$V_{motor} = V_{battery} - V_{drop} = 7.4 - 2.0 = 5.4\,\text{V}$$

This is fine for 3-6V TT motors. If you need more motor voltage, use a 3S pack (11.1V), but check that your motors can handle the resulting ~9V.

🔗Tutorial

🔗Step 1: Wire the L298N Motor Driver

The L298N has several terminal blocks and pins. Here is how to wire it:

L298N PinConnection
12V / VINBattery positive (7.4V through switch)
GNDBattery negative AND ESP32 GND
5V (output)ESP32 VIN pin
ENAESP32 GPIO 14 (PWM speed control, left motor)
IN1ESP32 GPIO 27
IN2ESP32 GPIO 26
IN3ESP32 GPIO 25
IN4ESP32 GPIO 33
ENBESP32 GPIO 32 (PWM speed control, right motor)
OUT1Left motor terminal 1
OUT2Left motor terminal 2
OUT3Right motor terminal 1
OUT4Right motor terminal 2

Remove the ENA and ENB jumpers on the L298N board. These jumpers connect the enable pins directly to 5V (full speed, always on). We want PWM speed control, so we need to drive ENA and ENB from the ESP32.

Important: The L298N GND must be connected to the ESP32 GND. Without a common ground, signals between the boards will not work.

The motor direction is controlled by IN1-IN4:

IN1IN2Left Motor
HIGHLOWForward
LOWHIGHBackward
LOWLOWStop (coast)
HIGHHIGHStop (brake)

The same logic applies to IN3/IN4 for the right motor.

🔗Step 2: Wire the HC-SR04 Ultrasonic Sensor

HC-SR04 PinESP32 Pin
VCC5V (from ESP32)
GNDGND
TRIGGPIO 5
ECHOGPIO 18

The HC-SR04 operates at 5V logic, but GPIO 18 on the ESP32 is 5V-tolerant as an input (the internal protection diodes handle the 5V ECHO signal). For a more robust setup, you can use a voltage divider (two resistors) on the ECHO line to step it down to 3.3V, but in practice most ESP32 boards work fine without one.

The sensor measures distance by sending a 10 microsecond pulse on TRIG and timing how long the ECHO pin stays HIGH:

$$d = \frac{t \times v_{sound}}{2} = \frac{t \times 343}{2 \times 10000} \,\text{cm}$$

where $t$ is the echo time in microseconds, and we divide by 2 because the sound travels to the object and back.

🔗Step 3: Wire the Servo Motor

The servo mounts the HC-SR04 on top of it so the sensor can sweep left and right.

Servo WireESP32 Pin
Red (VCC)5V (from ESP32)
Brown/Black (GND)GND
Orange/Yellow (Signal)GPIO 13

Mount the servo on the front of the chassis (pointing forward) and attach the HC-SR04 on top of the servo horn using a small bracket, hot glue, or zip ties. The servo will rotate the sensor left, center, and right to scan for obstacles.

🔗Step 4: Complete Wiring Summary

Here is every connection in one table:

ESP32 PinConnected ToPurpose
VINL298N 5V outputPower from motor driver
GNDL298N GND, HC-SR04 GND, Servo GNDCommon ground
GPIO 14L298N ENALeft motor speed (PWM)
GPIO 27L298N IN1Left motor direction
GPIO 26L298N IN2Left motor direction
GPIO 25L298N IN3Right motor direction
GPIO 33L298N IN4Right motor direction
GPIO 32L298N ENBRight motor speed (PWM)
GPIO 5HC-SR04 TRIGUltrasonic trigger
GPIO 18HC-SR04 ECHOUltrasonic echo
GPIO 13Servo signalSensor sweep
5VHC-SR04 VCC, Servo VCCSensor and servo power

🔗Step 5: Upload the Code

This sketch is the complete robot car firmware. It includes the web server, joystick interface, motor control, and autonomous obstacle avoidance mode.

#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>

// ==== WiFi CONFIGURATION ====
const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

// ==== MOTOR PINS ====
#define ENA  14   // Left motor speed (PWM)
#define IN1  27   // Left motor direction
#define IN2  26
#define IN3  25   // Right motor direction
#define IN4  33
#define ENB  32   // Right motor speed (PWM)

// ==== SENSOR PINS ====
#define TRIG_PIN  5
#define ECHO_PIN  18
#define SERVO_PIN 13

// ==== PWM CONFIGURATION ====
#define PWM_FREQ      1000
#define PWM_RESOLUTION 8     // 0-255
#define PWM_CHANNEL_A  0
#define PWM_CHANNEL_B  1

// ==== OBSTACLE AVOIDANCE ====
#define STOP_DISTANCE    25   // cm - stop and look around
#define SLOW_DISTANCE    50   // cm - reduce speed
#define MAX_DISTANCE     300  // cm - ignore readings beyond this

// ==== OBJECTS ====
WebServer server(80);
Servo scanServo;

// ==== STATE ====
bool autonomousMode = false;
int currentSpeed = 200;       // 0-255

// ==============================
//       MOTOR FUNCTIONS
// ==============================

void motorsInit() {
    pinMode(IN1, OUTPUT);
    pinMode(IN2, OUTPUT);
    pinMode(IN3, OUTPUT);
    pinMode(IN4, OUTPUT);

    ledcSetup(PWM_CHANNEL_A, PWM_FREQ, PWM_RESOLUTION);
    ledcSetup(PWM_CHANNEL_B, PWM_FREQ, PWM_RESOLUTION);
    ledcAttachPin(ENA, PWM_CHANNEL_A);
    ledcAttachPin(ENB, PWM_CHANNEL_B);

    motorsStop();
}

void setMotors(int leftSpeed, int rightSpeed) {
    // leftSpeed and rightSpeed: -255 to 255
    // Positive = forward, negative = backward

    // Left motor
    if (leftSpeed > 0) {
        digitalWrite(IN1, HIGH);
        digitalWrite(IN2, LOW);
    } else if (leftSpeed < 0) {
        digitalWrite(IN1, LOW);
        digitalWrite(IN2, HIGH);
    } else {
        digitalWrite(IN1, LOW);
        digitalWrite(IN2, LOW);
    }
    ledcWrite(PWM_CHANNEL_A, abs(leftSpeed));

    // Right motor
    if (rightSpeed > 0) {
        digitalWrite(IN3, HIGH);
        digitalWrite(IN4, LOW);
    } else if (rightSpeed < 0) {
        digitalWrite(IN3, LOW);
        digitalWrite(IN4, HIGH);
    } else {
        digitalWrite(IN3, LOW);
        digitalWrite(IN4, LOW);
    }
    ledcWrite(PWM_CHANNEL_B, abs(rightSpeed));
}

void motorsStop() {
    setMotors(0, 0);
}

void moveForward(int speed) {
    setMotors(speed, speed);
}

void moveBackward(int speed) {
    setMotors(-speed, -speed);
}

void turnLeft(int speed) {
    setMotors(-speed, speed);
}

void turnRight(int speed) {
    setMotors(speed, -speed);
}

// ==============================
//      ULTRASONIC SENSOR
// ==============================

float measureDistance() {
    digitalWrite(TRIG_PIN, LOW);
    delayMicroseconds(2);
    digitalWrite(TRIG_PIN, HIGH);
    delayMicroseconds(10);
    digitalWrite(TRIG_PIN, LOW);

    long duration = pulseIn(ECHO_PIN, HIGH, 30000); // 30ms timeout

    if (duration == 0) return MAX_DISTANCE;  // No echo = no obstacle

    float distance = duration * 0.0343 / 2.0;
    return (distance > MAX_DISTANCE) ? MAX_DISTANCE : distance;
}

// ==============================
//       SERVO SCANNING
// ==============================

float scanDirection(int angle) {
    // angle: 0 = right, 90 = center, 180 = left
    scanServo.write(angle);
    delay(300);  // Wait for servo to reach position
    float d = measureDistance();
    delay(50);
    return d;
}

// ==============================
//    AUTONOMOUS NAVIGATION
// ==============================

void autonomousLoop() {
    // Look straight ahead
    float frontDist = scanDirection(90);

    Serial.printf("Front: %.0f cm\n", frontDist);

    if (frontDist > SLOW_DISTANCE) {
        // Clear ahead - full speed
        moveForward(currentSpeed);
    }
    else if (frontDist > STOP_DISTANCE) {
        // Getting close - slow down
        int slowSpeed = map(frontDist, STOP_DISTANCE, SLOW_DISTANCE,
                            currentSpeed / 3, currentSpeed);
        moveForward(slowSpeed);
    }
    else {
        // Obstacle too close - stop and scan
        motorsStop();
        delay(200);

        // Scan left and right
        float leftDist  = scanDirection(180);  // Look left
        float rightDist = scanDirection(0);    // Look right
        scanServo.write(90);                   // Return to center

        Serial.printf("Left: %.0f cm, Right: %.0f cm\n",
                       leftDist, rightDist);

        if (leftDist > STOP_DISTANCE && leftDist >= rightDist) {
            // More room on the left - turn left
            Serial.println("Turning left");
            turnLeft(currentSpeed);
            delay(400);
        }
        else if (rightDist > STOP_DISTANCE) {
            // More room on the right - turn right
            Serial.println("Turning right");
            turnRight(currentSpeed);
            delay(400);
        }
        else {
            // Blocked on all sides - reverse and turn around
            Serial.println("Dead end - reversing");
            moveBackward(currentSpeed);
            delay(500);
            turnRight(currentSpeed);
            delay(600);
        }
        motorsStop();
        delay(100);
    }
}

// ==============================
//        WEB SERVER
// ==============================

const char HTML_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1,
      maximum-scale=1, user-scalable=no">
<title>Robot Car Control</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: sans-serif;
    background: #1a1a2e;
    color: #eee;
    display: flex;
    flex-direction: column;
    align-items: center;
    height: 100vh;
    overflow: hidden;
    touch-action: none;
  }
  h1 { font-size: 1.3em; margin: 10px 0 5px; color: #81c784; }
  .info { font-size: 0.85em; color: #aaa; margin-bottom: 8px; }
  .controls {
    display: flex;
    gap: 15px;
    margin: 8px 0;
    flex-wrap: wrap;
    justify-content: center;
  }
  .btn {
    padding: 10px 18px;
    border: none;
    border-radius: 8px;
    font-size: 1em;
    cursor: pointer;
    color: #fff;
    min-width: 80px;
  }
  .btn-auto { background: #e65100; }
  .btn-auto.active { background: #4caf50; }
  .btn-stop { background: #c62828; font-size: 1.2em; padding: 12px 30px; }
  #joystick-container {
    width: 250px;
    height: 250px;
    background: radial-gradient(circle, #16213e, #0f3460);
    border-radius: 50%;
    border: 3px solid #444;
    position: relative;
    margin: 15px 0;
  }
  #joystick {
    width: 70px;
    height: 70px;
    background: radial-gradient(circle, #64b5f6, #1565c0);
    border-radius: 50%;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    box-shadow: 0 0 15px rgba(100,181,246,0.5);
  }
  .speed-control {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 5px 0;
  }
  input[type=range] { width: 180px; }
  #distance { font-size: 1.1em; color: #ffb74d; margin: 5px 0; }
</style>
</head>
<body>
<h1>Robot Car Control</h1>
<p class="info" id="status">Connecting...</p>
<p id="distance">Distance: -- cm</p>

<div class="controls">
  <button class="btn btn-auto" id="autoBtn"
          onclick="toggleAuto()">AUTO: OFF</button>
  <button class="btn btn-stop"
          onclick="sendCmd('/stop')">STOP</button>
</div>

<div class="speed-control">
  <span>Speed:</span>
  <input type="range" min="80" max="255" value="200"
         id="speedSlider" oninput="updateSpeed(this.value)">
  <span id="speedVal">200</span>
</div>

<div id="joystick-container">
  <div id="joystick"></div>
</div>

<script>
let autoMode = false;
let sending = false;
let lastCmd = '';

function sendCmd(cmd) {
  if (sending && cmd === lastCmd) return;
  sending = true;
  lastCmd = cmd;
  fetch(cmd).then(r => r.text()).then(t => {
    sending = false;
  }).catch(e => { sending = false; });
}

function toggleAuto() {
  autoMode = !autoMode;
  const btn = document.getElementById('autoBtn');
  btn.textContent = 'AUTO: ' + (autoMode ? 'ON' : 'OFF');
  btn.classList.toggle('active', autoMode);
  sendCmd(autoMode ? '/auto' : '/manual');
}

function updateSpeed(val) {
  document.getElementById('speedVal').textContent = val;
  sendCmd('/speed?v=' + val);
}

// Joystick logic
const container = document.getElementById('joystick-container');
const stick = document.getElementById('joystick');
const centerX = 125, centerY = 125, maxR = 90;
let dragging = false;

function handleMove(clientX, clientY) {
  const rect = container.getBoundingClientRect();
  let x = clientX - rect.left - centerX;
  let y = clientY - rect.top - centerY;

  // Clamp to circle
  const dist = Math.sqrt(x*x + y*y);
  if (dist > maxR) { x = x/dist*maxR; y = y/dist*maxR; }

  stick.style.left = (centerX + x) + 'px';
  stick.style.top  = (centerY + y) + 'px';

  // Normalize to -100..100
  const nx = Math.round(x / maxR * 100);
  const ny = Math.round(-y / maxR * 100); // Invert Y

  sendCmd('/joy?x=' + nx + '&y=' + ny);
}

function resetStick() {
  stick.style.left = centerX + 'px';
  stick.style.top  = centerY + 'px';
  sendCmd('/stop');
  dragging = false;
}

container.addEventListener('mousedown', e => { dragging = true; });
document.addEventListener('mouseup', e => { if (dragging) resetStick(); });
document.addEventListener('mousemove', e => {
  if (dragging) handleMove(e.clientX, e.clientY);
});

container.addEventListener('touchstart', e => {
  dragging = true;
  e.preventDefault();
});
document.addEventListener('touchend', e => { if (dragging) resetStick(); });
document.addEventListener('touchmove', e => {
  if (dragging) {
    handleMove(e.touches[0].clientX, e.touches[0].clientY);
    e.preventDefault();
  }
}, { passive: false });

// Poll distance and status
setInterval(() => {
  fetch('/status').then(r => r.json()).then(d => {
    document.getElementById('distance').textContent =
      'Distance: ' + d.dist + ' cm';
    document.getElementById('status').textContent =
      'Mode: ' + d.mode + ' | IP: ' + d.ip;
  }).catch(e => {});
}, 500);
</script>
</body>
</html>
)rawliteral";

void handleRoot() {
    server.send(200, "text/html", HTML_PAGE);
}

void handleJoystick() {
    if (autonomousMode) {
        server.send(200, "text/plain", "In auto mode");
        return;
    }

    int x = server.arg("x").toInt();  // -100 to 100 (left/right)
    int y = server.arg("y").toInt();  // -100 to 100 (back/forward)

    // Convert joystick X,Y to differential drive (left/right motor speeds)
    // Y = forward/backward thrust, X = turning
    int leftSpeed  = constrain(y + x, -100, 100);
    int rightSpeed = constrain(y - x, -100, 100);

    // Scale from -100..100 to -currentSpeed..currentSpeed
    leftSpeed  = map(leftSpeed,  -100, 100, -currentSpeed, currentSpeed);
    rightSpeed = map(rightSpeed, -100, 100, -currentSpeed, currentSpeed);

    setMotors(leftSpeed, rightSpeed);

    server.send(200, "text/plain", "OK");
}

void handleStop() {
    autonomousMode = false;
    motorsStop();
    server.send(200, "text/plain", "Stopped");
}

void handleAuto() {
    autonomousMode = true;
    scanServo.write(90);  // Center the sensor
    server.send(200, "text/plain", "Autonomous mode ON");
}

void handleManual() {
    autonomousMode = false;
    motorsStop();
    server.send(200, "text/plain", "Manual mode ON");
}

void handleSpeed() {
    currentSpeed = constrain(server.arg("v").toInt(), 0, 255);
    server.send(200, "text/plain", String(currentSpeed));
}

void handleStatus() {
    float dist = measureDistance();
    String json = "{\"dist\":" + String(dist, 0)
                + ",\"mode\":\"" + (autonomousMode ? "Auto" : "Manual") + "\""
                + ",\"ip\":\"" + WiFi.localIP().toString() + "\""
                + ",\"speed\":" + String(currentSpeed)
                + "}";
    server.send(200, "application/json", json);
}

// ==============================
//        SETUP & LOOP
// ==============================

void setup() {
    Serial.begin(115200);
    delay(1000);
    Serial.println("\n=== RC Robot Car ===");

    // Initialize motors
    motorsInit();
    Serial.println("Motors initialized");

    // Initialize ultrasonic sensor
    pinMode(TRIG_PIN, OUTPUT);
    pinMode(ECHO_PIN, INPUT);

    // Initialize servo
    scanServo.attach(SERVO_PIN);
    scanServo.write(90);  // Center position
    delay(500);
    Serial.println("Servo centered");

    // Connect WiFi
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);
    Serial.print("Connecting to WiFi");
    int attempts = 0;
    while (WiFi.status() != WL_CONNECTED && attempts < 30) {
        delay(500);
        Serial.print(".");
        attempts++;
    }

    if (WiFi.status() == WL_CONNECTED) {
        Serial.printf("\nConnected! IP: %s\n",
                       WiFi.localIP().toString().c_str());
    } else {
        Serial.println("\nWiFi failed! Starting AP mode...");
        // Fallback: create own access point
        WiFi.softAP("RobotCar", "12345678");
        Serial.printf("AP started. Connect to 'RobotCar' WiFi.\n");
        Serial.printf("Open http://%s\n",
                       WiFi.softAPIP().toString().c_str());
    }

    // Setup web server routes
    server.on("/",       handleRoot);
    server.on("/joy",    handleJoystick);
    server.on("/stop",   handleStop);
    server.on("/auto",   handleAuto);
    server.on("/manual", handleManual);
    server.on("/speed",  handleSpeed);
    server.on("/status", handleStatus);
    server.begin();
    Serial.println("Web server started");

    Serial.println("Ready! Open the IP address in your phone browser.");
}

void loop() {
    server.handleClient();

    if (autonomousMode) {
        autonomousLoop();
    }

    delay(10);
}

🔗Step 6: Upload and Initial Test

  1. Connect the ESP32 via USB (leave the battery disconnected for now)
  2. Upload the sketch from the Arduino IDE
  3. Open Serial Monitor at 115200 baud
  4. Note the IP address printed after WiFi connection
  5. Open that IP address in your phone's browser

At this point, you should see the joystick interface. The motors will not spin yet because there is no battery power to the L298N, but you can verify that the web interface loads and the distance sensor reads values.

🔗Step 7: Motor Direction Test

Before assembling everything on the chassis, verify the motor directions:

  1. Connect the battery pack through the switch
  2. On the web interface, push the joystick forward
  3. Both motors should spin the same way (forward)
  4. If one motor spins backward, swap the two wires for that motor on the L298N output terminals (swap OUT1/OUT2 or OUT3/OUT4)
  5. Push the joystick left/right and verify the car would turn correctly (left = left motor slower or reversed, right motor faster)

Tip: If the car veers to one side when driving straight, the motors have slightly different speeds. You can add a software trim factor: multiply one motor's speed by 0.9 or 0.95 to compensate.

🔗Step 8: Assemble on the Chassis

Mount the components on the chassis in this general layout:

     Front (caster wheel)
    ┌──────────────────────┐
    │     [Servo+HC-SR04]  │
    │                      │
    │    [Breadboard]      │
    │    [ESP32]           │
    │                      │
    │    [L298N]           │
    │                      │
    │  [Battery Pack]      │
    │                      │
    │ [Motor L]  [Motor R] │
    └──────────────────────┘
     Back (drive wheels)

Key mounting tips:

  • The servo and HC-SR04 go at the front so the sensor has a clear view
  • The L298N should be near the motors to keep motor wires short (short wires reduce electrical noise)
  • The battery pack can go underneath the chassis to lower the center of gravity
  • Keep the ESP32 accessible so you can reach the USB port for reprogramming
  • Use zip ties, double-sided tape, or standoffs to secure everything

🔗Step 9: Test Autonomous Mode

  1. Place the car on the floor with some obstacles (books, boxes, chair legs)
  2. Open the web interface on your phone
  3. Press the AUTO button
  4. The car should drive forward, slow down near obstacles, and turn to avoid them
  5. Press STOP at any time to halt the car

The autonomous algorithm works like this:

graph TD
    A[Measure front distance] --> B{> 50 cm?}
    B -->|Yes| C[Drive forward<br/>full speed]
    C --> A
    B -->|No| D{> 25 cm?}
    D -->|Yes| E[Drive forward<br/>reduced speed]
    E --> A
    D -->|No| F[Stop]
    F --> G[Scan left]
    G --> H[Scan right]
    H --> I{Left > Right<br/>AND Left > 25cm?}
    I -->|Yes| J[Turn left]
    I -->|No| K{Right > 25cm?}
    K -->|Yes| L[Turn right]
    K -->|No| M[Reverse and<br/>turn around]
    J --> A
    L --> A
    M --> A

🔗The Joystick Math: Differential Drive

The joystick gives us an X value (left/right, -100 to 100) and a Y value (forward/backward, -100 to 100). We need to convert these into separate left and right motor speeds. This is called differential drive or tank mixing.

The formula used in the code is:

$$\text{leftSpeed} = Y + X$$ $$\text{rightSpeed} = Y - X$$

For example:

  • Joystick full forward ($X=0, Y=100$): left = 100, right = 100 (both motors forward, straight)
  • Joystick full right ($X=100, Y=0$): left = 100, right = -100 (pivot right)
  • Joystick forward-right ($X=50, Y=100$): left = 150 (clamped to 100), right = 50 (wide right turn)

The values are clamped to the -100 to 100 range and then mapped to the actual PWM range ($0{-}255$).

🔗Common Issues and Solutions

ProblemCauseFix
Motors do not spinNo battery power, or ENA/ENB jumpers still installedConnect battery, remove ENA/ENB jumpers on the L298N
One motor spins wrong directionMotor wires swappedSwap the two wires for that motor on the L298N output terminals
Car veers to one sideMotors have different speedsAdd a trim multiplier in code (e.g., rightSpeed *= 0.93)
ESP32 resets when motors startVoltage drop / brownoutEnsure common GND. Add a 470uF capacitor across the L298N 5V and GND pins
Servo jitters or buzzesInsufficient current or noisy signalPower servo from a dedicated 5V supply. Add a 100uF capacitor near the servo
HC-SR04 reads 0 or maxBad wiring or echo timeoutCheck TRIG/ECHO connections. Ensure the sensor is not blocked or angled at the floor
Web page doesn't loadWrong IP, or WiFi not connectedCheck Serial Monitor for IP. If WiFi fails, the code creates an AP named "RobotCar"
Joystick is unresponsiveBrowser issue or WiFi lagTry a different browser. Reduce WiFi distance. The car must be on the same network
Motors run at full speed, no PWMENA/ENB jumpers still installedRemove the jumpers so the ESP32 controls speed via PWM
Car flips or tips overTop-heavy or turning too fastLower the battery to the bottom of the chassis. Reduce max speed
"ESP32Servo.h not found"Library not installedInstall "ESP32Servo" by Kevin Harrington from Library Manager
AP mode but no web pagePhone auto-switches to mobile dataDisable mobile data temporarily so the phone stays on the RobotCar WiFi

🔗Improving Motor Control

🔗Minimum PWM Threshold

Most DC motors will not spin below a certain PWM duty cycle. They just hum without rotating. This is because the motor needs a minimum voltage to overcome friction. For typical TT gearbox motors, the minimum useful PWM value is around 60-80 (out of 255).

In the joystick handler, you can add a dead zone:

// Add after calculating leftSpeed / rightSpeed:
if (abs(leftSpeed) < 60) leftSpeed = 0;
if (abs(rightSpeed) < 60) rightSpeed = 0;

🔗Acceleration Ramping

Sudden speed changes are hard on the motors and battery, and can cause the ESP32 to brownout. You can add smooth acceleration by gradually changing the speed:

void rampMotors(int targetLeft, int targetRight, int stepDelay) {
    static int curLeft = 0, curRight = 0;
    while (curLeft != targetLeft || curRight != targetRight) {
        if (curLeft < targetLeft) curLeft++;
        else if (curLeft > targetLeft) curLeft--;
        if (curRight < targetRight) curRight++;
        else if (curRight > targetRight) curRight--;
        setMotors(curLeft, curRight);
        delay(stepDelay);
    }
}

🔗Battery Life

With a 2S 18650 pack ($2 \times 2500\,\text{mAh}$ in series = $2500\,\text{mAh}$ at $7.4\,\text{V}$), the run time depends on driving style:

Driving StyleAvg Current DrawEstimated Run Time
Gentle driving (low speed)~400 mA~6 hours
Normal driving~700 mA~3.5 hours
Aggressive (full speed, lots of turns)~1.2 A~2 hours
Autonomous mode~600 mA~4 hours

Low battery warning: When the 2S pack drops below about $6.4\,\text{V}$, the L298N's 5V regulator may not have enough headroom, causing ESP32 brownouts. If the car starts behaving erratically, recharge the batteries.

🔗Extensions

  • Speed display on OLED: Add an SSD1306 OLED to show the current speed, distance, mode, and battery voltage.
  • Line following: Add two IR line-tracking sensors to the bottom of the car for line-following mode.
  • Headlights: Wire two white LEDs to a GPIO pin so the car can light up dark areas.
  • Horn: Add a buzzer triggered by a button on the web interface.
  • Camera: Mount an ESP32-CAM as a separate module on the car, streaming video to your phone while driving. Control would come from the main ESP32, video from the ESP32-CAM.
  • Encoders: Add rotary encoders to the motor shafts for precise distance tracking and PID speed control.
  • Bluetooth control: Replace WiFi with Bluetooth Classic SPP for lower-latency control, paired with a phone app or custom gamepad.
  • GPS navigation: Add a GPS module and have the car navigate to waypoints outdoors.