Community project

ESP32 Remote-Controlled Car

מנדי קניג

Published August 9, 2026 · Updated August 11, 2026

ESP326 components6 assembly steps
Remix this project
Photo of ESP32 Remote-Controlled CarGenerated with AI

Build a WiFi-controlled car powered by an ESP32 that streams live video while you drive it remotely. The robot uses two geared motors for propulsion, a servo for steering, and an onboard camera to see where it's going. A protected 2S battery pack keeps everything running safely.

This guide covers the complete build: wiring the battery protection board and buck converter for clean power rails, connecting the dual motor driver to control both drive motors independently, setting up the servo steering signal, and mounting the camera module. Assembly steps walk through safe testing at reduced speeds before running at full throttle, plus the firmware provides a web interface for remote control and live video streaming over WiFi.

Wiring diagram

Interactive · read-only
Wiring diagram for ESP32 Remote-Controlled Car

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
MG90S Micro ServoMG90S1Tower Pro MG90S 9g metal-gear micro servo motor. Controlled via a standard 50 Hz PWM signal (1–2 ms pulse width). Operating voltage 4.8 V–6 V. Three-wire interface: VCC (red), GND (brown/black), and Signal (orange/yellow). Stall torque 1.8 kg·cm at 4.8 V / 2.2 kg·cm at 6 V. Rotation range 0°–180°.
TB6612FNG dual motor driverTB6612FNG1TB6612FNG dual H-bridge motor driver breakout with raw AIN1/AIN2/PWMA and BIN1/BIN2/PWMB control inputs plus STBY. The motor terminals connect to the driver outputs, not directly to MCU GPIO.
MP1584 Buck ConverterSet to 5.0 V1Adjustable buck (step-down) DC-DC converter, 4.5-28 V in -> 0.8-20 V out, ~3 A. Configured to 5 V to step a 9 V / 12 V supply or battery pack down to the board's 5 V rail.
JGA25-370 6 V 620 RPM Gearmotor6 V, 620 RPM1The geared DC motor that drives the car forward and backward.
2S 18650 Battery Pack2S, 7.4 V nominal1Two matched 18650 lithium-ion cells in series that provide the car's portable power.
2S 18650 Protection Board (BMS)2S Li-ion BMS, >= 5 A1A protection board that prevents unsafe over-charge, over-discharge, and over-current from the two-cell battery pack.

Assembly

6 steps
  1. Make the protected battery supply

    Put two identical, equally charged 18650 cells in a proper 2-cell holder. Connect battery_1 BAT+ to bms_1 B+ (battery positive), and battery_1 BAT- to bms_1 B- (battery negative). From the protected output, connect bms_1 P+ to buck_1 VIN (buck input power) and to motor_driver_1 VM (raw motor power). Connect bms_1 P- to buck_1 GND and the shared GND rail (ground).

    • Tip: Use thicker wire for the battery, VM, GND, and motor wires because these carry the most current.
    • Tip: The battery-to-VM path does not go through the buck converter.
    • A fully charged two-cell pack is about 8.4 V. Reversed battery wires can damage the BMS, driver, and wiring.
    • Use matched lithium cells and a real 2S charger; do not charge loose cells directly.
  2. Set up the 5 V rail

    Before connecting the servo or ESP32, use a multimeter on buck_1 VOUT and GND and adjust its small screw until the reading is exactly 5.0 V. Connect buck_1 VOUT to servo_1 VCC, the red wire (power), and to the Freenove ESP32-S3 camera board 5V/VIN pin (power). Connect buck_1 GND to servo_1 GND, the brown or black wire (ground), and to an ESP32 GND pin (ground).

    • Tip: Label the buck output wires red for 5 V and black for ground.
    • Tip: A 470–1000 µF capacitor across the 5 V and GND rails close to the servo can help if the camera resets while steering.
    • Do not connect the raw battery to the servo or the ESP32 5V/VIN pin — a full battery can reach 8.4 V and can damage them.
    • Do not swap the servo red and brown/black wires; swapped power can damage the servo.
  3. Wire the motor driver

    Connect motor_driver_1 VM to bms_1 P+ (raw motor power) and motor_driver_1 GND to bms_1 P- / the shared ground (ground). Connect motor_driver_1 VCC to the ESP32 3V3 pin (logic power). Connect A01 to motor_1 M+ (motor output) and A02 to motor_1 M- (motor output). Connect AIN1 to GPIO1 (direction signal), AIN2 to GPIO2 (direction signal), PWMA to GPIO14 (slow/fast power signal), and STBY to GPIO3 (driver enable signal). Connect BIN1, BIN2, and PWMB to GND because the second motor output is unused.

    • Tip: If Forward makes the wheel run backward, swap the two motor wires at A01 and A02.
    • Tip: Keep the motor wires away from the camera antenna and signal wires where you can.
    • Never connect the motor straight to an ESP32 pin; the motor current would damage the board.
    • The 6 V motor receives PWM pulses from the 2S pack. Fast is limited in software to keep average drive near 6 V, but it does not turn an 8.4 V battery into a physical 6 V supply.
  4. Connect the steering signal

    Connect ESP32 GPIO38 directly to servo_1 SIG, the orange or yellow wire (steering signal). There is no level-shifter board in this version. The servo uses the ESP32's 3.3 V control signal while its red power wire stays on the separate 5 V buck output.

    • Tip: Make this signal wire short and secure so steering does not glitch as the car vibrates.
    • Tip: The ESP32, motor driver, buck, and servo must all share the same GND connection.
    • Do not connect the servo signal wire to the 5 V power wire. That can damage the ESP32 output pin.
  5. Mount and power the camera board

    Secure the Freenove camera board facing forward, with its antenna area and lens clear. With the battery disconnected, check that the buck's 5 V output reaches the board's 5V/VIN pin and its GND reaches an ESP32 GND pin. Fit the servo horn to the steering linkage only after first power-up, when the program has moved the servo to centre.

    • Tip: Use cable ties or hot glue so vibration cannot pull wires out.
    • Tip: Keep the camera lens clear of the wheel and steering linkage.
    • Remove battery power before changing any wiring or touching the steering linkage.
    • Do not force the powered servo horn by hand; its small gears can strip.
  6. Test slow and fast safely

    Lift the driven wheel off the table. Plug the battery into the protected BMS output to power the car. Join your phone to Wi-Fi RC-CAR-CAM using password drivecar, then open http://192.168.4.1. Select Slow or Fast, then hold Forward or Reverse; releasing the button stops the motor. Use Centre steering before driving.

    • Tip: Slow is selected when the page opens; use it first to check wheel direction and steering.
    • Tip: The motor stops automatically if the phone stops sending drive commands.
    • Keep fingers, hair, and loose wires away from the wheel and steering linkage.
    • Disconnect the battery at once if you smell hot plastic, see smoke, or feel a wire becoming hot.

Pin assignments

Board wiring reference
PinConnectionType
EXTbattery_1 BAT+2S 18650 Protection Board (BMS) B+power
EXTbattery_1 BAT-2S 18650 Protection Board (BMS) B-ground
GPIO 1motor_driver_1 AIN1digital
GPIO 2motor_driver_1 AIN2digital
GPIO 14motor_driver_1 PWMApwm
GNDmotor_driver_1 BIN1ground
GNDmotor_driver_1 BIN2ground
GNDmotor_driver_1 PWMBground
GPIO 3motor_driver_1 STBYdigital
EXTmotor_driver_1 A01JGA25-370 6 V 620 RPM Gearmotor M+digital
EXTmotor_driver_1 A02JGA25-370 6 V 620 RPM Gearmotor M-digital
5Vservo_1 VCCpower
GNDservo_1 GNDground
VINbuck_1 VINpower
GNDbuck_1 GNDground
GPIO 38servo_1 SIGpwm
VINbuck_1 VOUTpower
3V3motor_driver_1 VCCpower
EXTbms_1 P+TB6612FNG dual motor driver VMpower
EXTbms_1 P-TB6612FNG dual motor driver GNDground
EXTmotor_driver_1 VMMP1584 Buck Converter VINpower
EXTmotor_driver_1 GNDMP1584 Buck Converter GNDground

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <WiFi.h>
#include <ESP32Servo.h>
#include "esp_camera.h"
#include "esp_http_server.h"

// Freenove ESP32-S3 WROOM CAM OV2640 factory wiring.
#define PWDN_GPIO_NUM    -1
#define RESET_GPIO_NUM   -1
#define XCLK_GPIO_NUM    15
#define SIOD_GPIO_NUM     4
#define SIOC_GPIO_NUM     5
#define Y9_GPIO_NUM      16
#define Y8_GPIO_NUM      17
#define Y7_GPIO_NUM      18
#define Y6_GPIO_NUM      12
#define Y5_GPIO_NUM      10
#define Y4_GPIO_NUM       8
#define Y3_GPIO_NUM       9
#define Y2_GPIO_NUM      11
#define VSYNC_GPIO_NUM    6
#define HREF_GPIO_NUM     7
#define PCLK_GPIO_NUM    13


// Forward declarations
void stopMotor();
void driveMotor(bool forward);
esp_err_t indexHandler(httpd_req_t *request);
esp_err_t controlHandler(httpd_req_t *request);
esp_err_t streamHandler(httpd_req_t *request);
bool startCamera();
void startWebServer();

const int MOTOR_AIN1_PIN = 1;
const int MOTOR_AIN2_PIN = 2;
const int MOTOR_STBY_PIN = 3;
const int MOTOR_PWMA_PIN = 14;
// GPIO38 connects directly to the servo's orange/yellow control wire.
const int SERVO_PIN = 38;
const char *AP_NAME = "RC-CAR-CAM";
const char *AP_PASSWORD = "drivecar";
// The BMS feeds TB6612 VM directly; the MP1584 VIN shares that protected battery rail.
// A full 2S pack is 8.4 V. These PWM limits keep average motor drive near or below its 6 V rating.
const uint8_t DRIVE_POWER_SLOW = 105; // about 3.5 V average at a full battery
const uint8_t DRIVE_POWER_FAST = 180; // about 5.9 V average at a full battery
uint8_t selectedDrivePower = DRIVE_POWER_SLOW;
const int STEER_LEFT = 55;
const int STEER_CENTRE = 90;
const int STEER_RIGHT = 125;
const unsigned long COMMAND_TIMEOUT_MS = 700;

Servo steering;
httpd_handle_t cameraServer = nullptr;
unsigned long lastDriveCommandMs = 0;

void stopMotor() {
  digitalWrite(MOTOR_AIN1_PIN, LOW);
  digitalWrite(MOTOR_AIN2_PIN, LOW);
  ledcWrite(MOTOR_PWMA_PIN, 0);
}

void driveMotor(bool forward) {
  digitalWrite(MOTOR_STBY_PIN, HIGH);
  digitalWrite(MOTOR_AIN1_PIN, forward ? HIGH : LOW);
  digitalWrite(MOTOR_AIN2_PIN, forward ? LOW : HIGH);
  ledcWrite(MOTOR_PWMA_PIN, selectedDrivePower);
  lastDriveCommandMs = millis();
}

const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>RC Car Camera</title><style>body{margin:0;background:#15191f;color:#fff;font:18px Arial;text-align:center}h2{margin:14px}img{width:min(100%,640px);background:#000}.grid{display:grid;grid-template-columns:repeat(3,96px);gap:10px;justify-content:center;margin:16px}button{height:58px;border:0;border-radius:12px;background:#2684ff;color:#fff;font-size:17px;font-weight:bold}.stop{background:#e5484d}.note{font-size:13px;color:#b9c4d0;margin:8px 18px 20px}</style></head><body><h2>RC Car Camera</h2><img src="/stream" alt="Camera stream"><div class="grid"><div></div><button id="forward">Forward</button><div></div><button onclick="go('left')">Left</button><button class="stop" onclick="go('stop')">Stop</button><button onclick="go('right')">Right</button><div></div><button id="reverse">Reverse</button><div></div><button onclick="go('centre')">Centre steering</button></div><p><button id="slow" onclick="speed('slow')">Slow</button> <button id="fast" onclick="speed('fast')">Fast</button></p><p class="note">Choose a speed, then hold Forward or Reverse to drive. Releasing the button stops the motor.</p><script>function go(c){fetch('/control?cmd='+c).catch(()=>{});}function speed(s){go(s);document.getElementById('slow').style.background=s==='slow'?'#26a269':'#2684ff';document.getElementById('fast').style.background=s==='fast'?'#26a269':'#2684ff';}function hold(id,cmd){let t;const b=document.getElementById(id);const start=e=>{e.preventDefault();go(cmd);t=setInterval(()=>go(cmd),200);};const end=e=>{e.preventDefault();clearInterval(t);go('stop');};b.addEventListener('pointerdown',start);b.addEventListener('pointerup',end);b.addEventListener('pointerleave',end);b.addEventListener('pointercancel',end);}speed('slow');hold('forward','forward');hold('reverse','reverse');</script></body></html>
)HTML";

esp_err_t indexHandler(httpd_req_t *request) {
  httpd_resp_set_type(request, "text/html");
  return httpd_resp_send(request, INDEX_HTML, HTTPD_RESP_USE_STRLEN);
}

esp_err_t controlHandler(httpd_req_t *request) {
  char query[64] = {0};
  char command[16] = {0};
  if (httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK) httpd_query_key_value(query, "cmd", command, sizeof(command));
  if (strcmp(command, "forward") == 0) driveMotor(true);
  else if (strcmp(command, "reverse") == 0) driveMotor(false);
  else if (strcmp(command, "stop") == 0) stopMotor();
  else if (strcmp(command, "slow") == 0) selectedDrivePower = DRIVE_POWER_SLOW;
  else if (strcmp(command, "fast") == 0) selectedDrivePower = DRIVE_POWER_FAST;
  else if (strcmp(command, "left") == 0) steering.write(STEER_LEFT);
  else if (strcmp(command, "right") == 0) steering.write(STEER_RIGHT);
  else if (strcmp(command, "centre") == 0) steering.write(STEER_CENTRE);
  httpd_resp_set_type(request, "text/plain");
  return httpd_resp_sendstr(request, "OK");
}

esp_err_t streamHandler(httpd_req_t *request) {
  const char *contentType = "multipart/x-mixed-replace;boundary=frame";
  const char *boundary = "\r\n--frame\r\n";
  char header[64];
  httpd_resp_set_type(request, contentType);
  while (true) {
    camera_fb_t *frame = esp_camera_fb_get();
    if (!frame) return ESP_FAIL;
    esp_err_t result = httpd_resp_send_chunk(request, boundary, strlen(boundary));
    if (result == ESP_OK) {
      int length = snprintf(header, sizeof(header), "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", (unsigned)frame->len);
      result = httpd_resp_send_chunk(request, header, length);
    }
    if (result == ESP_OK) result = httpd_resp_send_chunk(request, (const char *)frame->buf, frame->len);
    esp_camera_fb_return(frame);
    if (result != ESP_OK) return result;
  }
}

bool startCamera() {
  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM; config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM; config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM; config.pin_pclk = PCLK_GPIO_NUM; config.pin_vsync = VSYNC_GPIO_NUM; config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM; config.pin_sccb_scl = SIOC_GPIO_NUM; config.pin_pwdn = PWDN_GPIO_NUM; config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_QVGA;
  config.jpeg_quality = 12;
  config.fb_count = 2;
  config.fb_location = CAMERA_FB_IN_PSRAM;
  config.grab_mode = CAMERA_GRAB_LATEST;
  return esp_camera_init(&config) == ESP_OK;
}

void startWebServer() {
  httpd_config_t config = HTTPD_DEFAULT_CONFIG();
  config.server_port = 80;
  if (httpd_start(&cameraServer, &config) != ESP_OK) return;
  httpd_uri_t indexUri = {}; indexUri.uri = "/"; indexUri.method = HTTP_GET; indexUri.handler = indexHandler;
  httpd_uri_t controlUri = {}; controlUri.uri = "/control"; controlUri.method = HTTP_GET; controlUri.handler = controlHandler;
  httpd_uri_t streamUri = {}; streamUri.uri = "/stream"; streamUri.method = HTTP_GET; streamUri.handler = streamHandler;
  httpd_register_uri_handler(cameraServer, &indexUri);
  httpd_register_uri_handler(cameraServer, &controlUri);
  httpd_register_uri_handler(cameraServer, &streamUri);
}

void setup() {
  Serial.begin(115200);
  pinMode(MOTOR_AIN1_PIN, OUTPUT); pinMode(MOTOR_AIN2_PIN, OUTPUT); pinMode(MOTOR_STBY_PIN, OUTPUT);
  digitalWrite(MOTOR_STBY_PIN, HIGH);
  ledcAttach(MOTOR_PWMA_PIN, 20000, 8);
  stopMotor();
  steering.setPeriodHertz(50);
  steering.attach(SERVO_PIN, 500, 2400);
  steering.write(STEER_CENTRE);
  if (!startCamera()) { Serial.println("Camera initialization failed"); return; }
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_NAME, AP_PASSWORD);
  Serial.print("Open http://"); Serial.println(WiFi.softAPIP());
  startWebServer();
}

void loop() {
  if (lastDriveCommandMs != 0 && millis() - lastDriveCommandMs > COMMAND_TIMEOUT_MS) { stopMotor(); lastDriveCommandMs = 0; }
  delay(10);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik