Bomb-disposal robots, pipe inspectors and the rovers on Mars are all driven by people who are not there. The driver sits somewhere safe, watches the video from the robot's camera, and steers it from a screen. In this capstone you build your own explorer. The ESP32-CAM is a tiny ESP32 board with a camera on it. It makes its own Wi-Fi network and sends a live video to your phone, and the same page has buttons to drive the car. Two motors on a cheap 2WD chassis drive the wheels through the L293D from your kit. Send the car under the sofa, into the next room or through an obstacle course, and steer it only by what you see on the screen.
What you'll learn
- How the ESP32-CAM takes JPEG pictures and sends them as a live video stream
- How one ESP32 can run two web servers: one for the video and one for the buttons
- How an L293D drives two wheels forwards and backwards, and how a car steers by running its wheels at different speeds
- Why the motors and the camera need separate power supplies
- Why a remote-controlled machine should stop by itself when it loses contact
What you need
- An ESP32-CAM board (AI-Thinker, with its OV2640 camera) and an ESP32-CAM-MB programmer board with a matching USB cable. They usually come together and are not in the kit. If your ESP32-CAM came without a camera, the OV2640 camera from the ESP32 IoT Starter Kit plugs into its camera socket.
- A 2WD robot car chassis kit: a chassis plate, two 3–6 V gear motors with wheels, a caster wheel and a battery holder for 4 AA batteries with an on/off switch (not in the kit)
- 4 AA batteries for the motors. Fresh alkaline batteries (6 V) give the most speed, because the L293D itself uses up about 2 V.
- A small USB power bank for the ESP32-CAM, and a micro-USB breakout board with VCC (5V) and GND pins, so the power bank's micro-USB cable can feed the breadboard
- L293D motor driver and a 100 µF capacitor (ESP32 IoT Starter Kit)
- A breadboard and male-to-male jumper wires (ESP32 IoT Starter Kit). A small 400-point breadboard fits better on the car.
- 6 female-to-male jumper wires for the ESP32-CAM pins. Take any microSD card out of the ESP32-CAM: its slot uses the same pins as the motors.
- The printed parts from the 3D printing box below (camera holder), or the cardboard version described there
- Two M3 screws and nuts, or strong double-sided tape
- A phone, tablet or laptop with Wi-Fi
Safety: the white flash LED on the ESP32-CAM is very bright: don't shine it into anyone's eyes. It also gets hot, so the sketch never lets it run at full power. Keep fingers and hair away from the wheels while the car is switched on, and take the batteries out before you change any wires. Switch the motor batteries off first and on last: while the ESP32-CAM starts, its pins are not set yet and a wheel could jump.
Upload the sketch first
The ESP32-CAM has no USB socket of its own. It sits on the ESP32-CAM-MB programmer board to get a sketch, and comes off again to go on the car. Test the camera on the programmer before you build anything.
#include "esp_camera.h"
#include "esp_http_server.h"
#include <WiFi.h>
// ---------- settings ----------
const char* CAR_WIFI = "CamRobotCar"; // the car makes its own Wi-Fi network
const char* CAR_PASSWORD = "change-me-123"; // at least 8 characters
const framesize_t PICTURE_SIZE = FRAMESIZE_VGA; // 640 x 480. FRAMESIZE_QVGA (320 x 240) is smoother
const int PICTURE_QUALITY = 12; // 10 = sharper, 20 = smaller and faster
const bool FLIP_PICTURE = false; // true if the picture is upside down
const bool REVERSE_LEFT = false; // true if the left wheel turns the wrong way
const bool REVERSE_RIGHT = false; // true if the right wheel turns the wrong way
const int SLOWEST = 130; // the lowest power (0-255) that still turns the wheels
const int MAX_LIGHT = 150; // the flash LED at 100 % (255 is full power and gets very hot)
const unsigned long TIMEOUT_MS = 500; // stop if the phone goes quiet for this long
// motors: ESP32-CAM pins to the L293D inputs
const int LEFT_A = 15, LEFT_B = 14; // L293D pin 2 (IN1) and pin 7 (IN2)
const int RIGHT_A = 12, RIGHT_B = 13; // L293D pin 10 (IN3) and pin 15 (IN4)
const int LIGHT_PIN = 4; // the white flash LED on the board
// the 9 drive buttons: wheel power in % (left, right) for q w e / a s d / z x c
const char KEYS[] = "qweasdzxc";
const int MIX[9][2] = {{35, 100}, {100, 100}, {100, 35},
{-70, 70}, {0, 0}, {70, -70},
{-35, -100}, {-100, -100}, {-100, -35}};
httpd_handle_t pageServer = NULL, streamServer = NULL;
volatile int wantLeft = 0, wantRight = 0; // -100 ... 100, set by the phone
volatile unsigned long lastCommand = 0;
int nowLeft = 0, nowRight = 0; // what the wheels get right now
unsigned long lastStep = 0;
bool cameraOK = false;
const char PAGE[] = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no"><title>Camera robot car</title>
<style>
body{margin:0;font-family:Arial,sans-serif;background:#F6F8FC;color:#1B3A7B;user-select:none;-webkit-user-select:none}
header{background:#1B3A7B;color:#fff;padding:10px 16px;display:flex;justify-content:space-between;align-items:center}
header b{font-size:20px} #info{font-size:14px;opacity:.85}
#view{position:relative;background:#000;aspect-ratio:4/3}
#cam{width:100%;height:100%;object-fit:contain;display:block}
#msg{position:absolute;left:0;right:0;top:40%;text-align:center;color:#fff;font-size:16px;padding:0 20px}
.pad{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px;max-width:420px}
.pad button{font-size:28px;padding:18px 0;border:0;border-radius:16px;background:#1B3A7B;color:#fff;touch-action:none}
.pad button.stop{background:#DE544A}
.pad button.on{background:#2F6FDB}
.box{background:#fff;margin:10px 12px;padding:10px 14px;border-radius:16px;box-shadow:0 2px 8px rgba(27,58,123,.08)}
.box div{display:flex;justify-content:space-between;font-weight:bold;font-size:17px}
input[type=range]{width:100%;height:40px;accent-color:#DE544A}
a.photo{display:block;margin:10px 12px 20px;padding:14px;border-radius:14px;background:#E3E8F2;color:#1B3A7B;text-align:center;font-weight:bold;text-decoration:none}
</style></head><body>
<header><b>Camera robot car</b><span id="info">Connecting...</span></header>
<div id="view"><img id="cam" alt=""><div id="msg"></div></div>
<div class="pad" id="pad"></div>
<div class="box"><div><span>Speed</span><span id="sv">60 %</span></div><input type="range" id="speed" min="30" max="100" value="60"></div>
<div class="box"><div><span>Light</span><span id="lv">off</span></div><input type="range" id="light" min="0" max="100" value="0"></div>
<a class="photo" href="/photo" target="_blank">Take a photo</a>
<script>
var KEYS = 'qweasdzxc', ARROWS = ['↖', '▲', '↗', '◀', '■', '▶', '↙', '▼', '↘'];
var key = 's', speed = 60, timer = null;
var pad = document.getElementById('pad');
KEYS.split('').forEach(function (k, i) {
var b = document.createElement('button');
b.innerHTML = ARROWS[i];
if (k == 's') b.className = 'stop';
b.onpointerdown = function (e) { e.preventDefault(); b.setPointerCapture(e.pointerId); press(k); };
b.onpointerup = b.onpointercancel = b.onlostpointercapture = function () { release(); };
b.oncontextmenu = function (e) { e.preventDefault(); };
pad.appendChild(b);
});
function send() {
fetch('/go?d=' + key + '&v=' + speed)
.then(function (r) { info(r.ok ? 'Connected' : 'Error'); })
.catch(function () { info('No connection'); });
}
function info(t) { document.getElementById('info').textContent = t; }
function press(k) {
key = k;
var buttons = pad.querySelectorAll('button');
for (var i = 0; i < 9; i++) buttons[i].classList.toggle('on', KEYS[i] == k && k != 's');
send();
clearInterval(timer);
if (k != 's') timer = setInterval(send, 150); // keep telling the car while a button is held
}
function release() { if (key != 's') press('s'); }
var ARROW_KEYS = { ArrowUp: 'w', ArrowDown: 'x', ArrowLeft: 'a', ArrowRight: 'd' };
document.onkeydown = function (e) {
var k = ARROW_KEYS[e.key] || e.key.toLowerCase();
if (KEYS.indexOf(k) >= 0) { e.preventDefault(); if (k != key && !e.repeat) press(k); }
};
document.onkeyup = release;
window.onblur = release;
document.onvisibilitychange = release;
document.getElementById('speed').oninput = function () { speed = this.value; document.getElementById('sv').textContent = speed + ' %'; };
document.getElementById('light').oninput = function () {
document.getElementById('lv').textContent = this.value == 0 ? 'off' : this.value + ' %';
fetch('/light?v=' + this.value).catch(function () {});
};
var cam = document.getElementById('cam');
cam.onerror = function () {
document.getElementById('msg').textContent = 'No picture. Check the camera ribbon cable, then press the RST button on the ESP32-CAM.';
};
cam.src = 'http://' + location.hostname + ':81/stream';
send();
</script></body></html>)rawliteral";
// ---------- camera (AI-Thinker ESP32-CAM pins) ----------
bool startCamera() {
camera_config_t c = {};
c.ledc_channel = LEDC_CHANNEL_0; // the camera clock uses PWM channel 0 and timer 0
c.ledc_timer = LEDC_TIMER_0;
c.pin_d0 = 5; c.pin_d1 = 18; c.pin_d2 = 19; c.pin_d3 = 21;
c.pin_d4 = 36; c.pin_d5 = 39; c.pin_d6 = 34; c.pin_d7 = 35;
c.pin_xclk = 0; c.pin_pclk = 22; c.pin_vsync = 25; c.pin_href = 23;
c.pin_sccb_sda = 26; c.pin_sccb_scl = 27;
c.pin_pwdn = 32; c.pin_reset = -1;
c.xclk_freq_hz = 20000000;
c.pixel_format = PIXFORMAT_JPEG; // the camera sends ready-made JPEG pictures
c.frame_size = PICTURE_SIZE;
c.jpeg_quality = PICTURE_QUALITY;
c.fb_count = 2; // two picture buffers: one is sent while the next is taken
c.fb_location = CAMERA_FB_IN_PSRAM;
c.grab_mode = CAMERA_GRAB_LATEST; // always send the newest picture
if (!psramFound()) {
c.frame_size = FRAMESIZE_QVGA;
c.fb_count = 1;
c.fb_location = CAMERA_FB_IN_DRAM;
}
if (esp_camera_init(&c) != ESP_OK) return false;
sensor_t* s = esp_camera_sensor_get();
s->set_vflip(s, FLIP_PICTURE);
s->set_hmirror(s, FLIP_PICTURE);
return true;
}
// ---------- web pages ----------
int queryNumber(httpd_req_t* req, const char* name, int fallback) {
char query[48], value[8];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return fallback;
if (httpd_query_key_value(query, name, value, sizeof(value)) != ESP_OK) return fallback;
return atoi(value);
}
esp_err_t pageHandler(httpd_req_t* req) {
httpd_resp_set_type(req, "text/html");
return httpd_resp_send(req, PAGE, HTTPD_RESP_USE_STRLEN);
}
esp_err_t goHandler(httpd_req_t* req) { // /go?d=w&v=60
char query[48], d[4] = "s";
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) httpd_query_key_value(query, "d", d, sizeof(d));
int speed = constrain(queryNumber(req, "v", 60), 0, 100);
const char* k = d[0] ? strchr(KEYS, d[0]) : NULL;
int i = k ? k - KEYS : 4; // unknown button: stop
wantLeft = MIX[i][0] * speed / 100;
wantRight = MIX[i][1] * speed / 100;
lastCommand = millis();
httpd_resp_set_type(req, "text/plain");
return httpd_resp_send(req, "ok", HTTPD_RESP_USE_STRLEN);
}
esp_err_t lightHandler(httpd_req_t* req) { // /light?v=0...100
int level = constrain(queryNumber(req, "v", 0), 0, 100);
ledcWrite(LIGHT_PIN, level * MAX_LIGHT / 100);
httpd_resp_set_type(req, "text/plain");
return httpd_resp_send(req, "ok", HTTPD_RESP_USE_STRLEN);
}
esp_err_t photoHandler(httpd_req_t* req) { // one picture as a JPEG file
camera_fb_t* fb = cameraOK ? esp_camera_fb_get() : NULL;
if (!fb) {
httpd_resp_send_500(req);
return ESP_FAIL;
}
httpd_resp_set_type(req, "image/jpeg");
httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=car-photo.jpg");
esp_err_t res = httpd_resp_send(req, (const char*)fb->buf, fb->len);
esp_camera_fb_return(fb);
return res;
}
// The live video: an endless list of JPEG pictures. The browser shows each new one in place of the last.
esp_err_t streamHandler(httpd_req_t* req) {
if (!cameraOK) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, "no camera", HTTPD_RESP_USE_STRLEN);
}
httpd_resp_set_type(req, "multipart/x-mixed-replace;boundary=frame");
char head[80];
while (true) {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) return ESP_FAIL;
int len = snprintf(head, sizeof(head), "\r\n--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", (unsigned)fb->len);
esp_err_t res = httpd_resp_send_chunk(req, head, len);
if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char*)fb->buf, fb->len);
esp_camera_fb_return(fb);
if (res != ESP_OK) return res; // the phone closed the page
}
}
void addPage(httpd_handle_t server, const char* uri, esp_err_t (*handler)(httpd_req_t*)) {
httpd_uri_t u = {};
u.uri = uri;
u.method = HTTP_GET;
u.handler = handler;
httpd_register_uri_handler(server, &u);
}
void startServers() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 80; // the page and the buttons
if (httpd_start(&pageServer, &config) == ESP_OK) {
addPage(pageServer, "/", pageHandler);
addPage(pageServer, "/go", goHandler);
addPage(pageServer, "/light", lightHandler);
addPage(pageServer, "/photo", photoHandler);
}
config.server_port = 81; // the video gets its own server, so it never blocks the buttons
config.ctrl_port = config.ctrl_port + 1;
if (httpd_start(&streamServer, &config) == ESP_OK) addPage(streamServer, "/stream", streamHandler);
}
// ---------- motors ----------
void setWheel(int pinA, int pinB, int power, bool reversed) { // power -100 ... 100
if (reversed) power = -power;
int duty = power == 0 ? 0 : map(abs(power), 1, 100, SLOWEST, 255);
ledcWrite(pinA, power > 0 ? duty : 0);
ledcWrite(pinB, power < 0 ? duty : 0);
}
void setup() {
Serial.begin(115200);
// PWM for the motors and the light, on channels 2 to 6 (the camera has channel 0)
int pins[5] = {LEFT_A, LEFT_B, RIGHT_A, RIGHT_B, LIGHT_PIN};
for (int i = 0; i < 5; i++) {
ledcAttachChannel(pins[i], i < 4 ? 1000 : 5000, 8, i + 2);
ledcWrite(pins[i], 0);
}
cameraOK = startCamera();
Serial.println(cameraOK ? "Camera ready" : "Camera not found: check the ribbon cable");
WiFi.setSleep(false); // faster video
if (!WiFi.softAP(CAR_WIFI, CAR_PASSWORD)) {
Serial.println("Wi-Fi failed: the password needs 8 to 63 characters");
}
startServers();
Serial.printf("Join the Wi-Fi \"%s\", then open http://", CAR_WIFI);
Serial.println(WiFi.softAPIP());
}
void loop() {
unsigned long last = lastCommand;
if (millis() - last > TIMEOUT_MS) { // no news from the phone: stop
wantLeft = 0;
wantRight = 0;
}
unsigned long t = millis();
if (t - lastStep >= 20) { // change the speed a little every 20 ms: smooth starts and stops
lastStep = t;
nowLeft += constrain(wantLeft - nowLeft, -10, 10);
nowRight += constrain(wantRight - nowRight, -10, 10);
setWheel(LEFT_A, LEFT_B, nowLeft, REVERSE_LEFT);
setWheel(RIGHT_A, RIGHT_B, nowRight, REVERSE_RIGHT);
}
delay(1);
}
- In the Arduino IDE, open Boards Manager and install esp32 by Espressif Systems (version 3 or newer), if you haven't already. The camera library comes with it, so there is nothing else to install.
- Choose Tools → Board → esp32 → AI Thinker ESP32-CAM. Under Tools → Partition Scheme, choose Huge APP (3MB No OTA/1MB SPIFFS).
- Check that the camera's ribbon cable is in its socket: lift the little dark latch, push the cable in with the gold contacts facing the board, and press the latch down.
- Plug the ESP32-CAM onto the programmer board so that the pin labels match (5V on 5V, GND on GND), connect it to the computer and choose its port under Tools → Port.
- Change
CAR_PASSWORDin the sketch to your own password of at least 8 characters, and upload. If the upload doesn't start, hold the IO0 button on the programmer, press and let go of RST, let go of IO0, and upload again. - Open the Serial Monitor at 115200 baud and press RST. It should say "Camera ready" and tell you which Wi-Fi to join.
- Join the CamRobotCar Wi-Fi with your phone and open
http://192.168.4.1. You should see the live picture. Point the camera at yourself and wave.
Wiring
The breadboard has two supplies. The top rails carry 5 V from the power bank, for the ESP32-CAM and the L293D's logic. The bottom rails carry the motor batteries, for the L293D's motor outputs. Only the two − rails are joined, never the + rails.

Build it step by step
Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.






| From | To |
|---|---|
| Breakout board VCC (5V) | Top + rail |
| Breakout board GND | Top − rail |
| Battery holder red wire (+) | Bottom + rail |
| Battery holder black wire (−) | Bottom − rail |
| A short wire | From the top − rail to the bottom − rail |
| 100 µF capacitor | Across the bottom + and − rails (stripe to −) |
| ESP32-CAM 5V | Top + rail |
| ESP32-CAM GND | Top − rail |
| ESP32-CAM IO15 | L293D pin 2 (IN1) |
| ESP32-CAM IO14 | L293D pin 7 (IN2) |
| ESP32-CAM IO12 | L293D pin 10 (IN3) |
| ESP32-CAM IO13 | L293D pin 15 (IN4) |
| L293D pins 1 (EN1), 9 (EN2) and 16 (VCC1) | Top + rail (5 V) |
| L293D pin 8 (VCC2) | Bottom + rail (motor batteries) |
| L293D pins 4, 5, 12 and 13 | Either − rail (they are joined) |
| L293D pins 3 and 6 (OUT1, OUT2) | The left motor's two wires |
| L293D pins 11 and 14 (OUT3, OUT4) | The right motor's two wires |
The L293D has a notch at one end. With the notch on the left, pin 1 is bottom left and the pins are counted anticlockwise, so pin 16 is top left. The chip sits across the middle gap of the breadboard. Use the female ends of the female-to-male wires on the ESP32-CAM's pins, and follow the labels printed next to its pins. For power, use the pin labelled 5V, not the one labelled VCC or 3V3.
Why two supplies? A motor that starts or gets blocked pulls a big burst of current, and the battery voltage dips for a moment. The ESP32-CAM is very sensitive to this: while it sends video, its radio already uses a lot of power, and a small dip makes it restart (the Serial Monitor then says "Brownout detector was triggered"). So the camera gets clean 5 V from the power bank, and the motors get their own batteries. The grounds are joined, because the L293D must measure the ESP32-CAM's signals against the same 0 V. The capacitor helps the batteries with the bursts. The L293D gets warm while the car drives: it turns about 2 V into heat, so the motors get a bit less than the batteries give.
Build the car
- Build the chassis as its instructions say: the two motors with their wheels at the back, the caster wheel at the front, and the battery holder underneath or on top.
- Stick the breadboard on the chassis plate with its own sticky back, or with double-sided tape. Put the L293D, the capacitor and the wires in, as in the wiring table.
- Screw the camera holder, with the ESP32-CAM already in it, to the front of the chassis: camera facing forwards, flash LED at the top.
- Connect the 6 female-to-male wires to the ESP32-CAM's pins behind the holder: 5V, GND, IO12, IO13, IO14 and IO15. Push each connector all the way on, then tape or hot-glue the six connectors together so they can't shake loose.
- Lead the motor wires to the breadboard: the left motor (seen from behind the car) to L293D pins 3 and 6, the right motor to pins 11 and 14.
- Find a place for the power bank, for example on top of the batteries, and fix it with a rubber band. Plug its cable into the breakout board.

Try it
- Put the car on the floor and plug in the power bank first. Wait about 2 seconds until the car is ready, then switch on the battery holder.
- Join the CamRobotCar Wi-Fi with your phone and your password. If the phone says the network has no internet, choose to stay connected. If the page doesn't open later, turn off mobile data for a moment.
- Open
http://192.168.4.1. The page says "Connected" and shows the live video from the car, with the drive buttons below. - Hold ▲: both wheels turn forwards and the car drives straight. Let go and it stops. Try ◀ and ▶ to turn on the spot, and the corner buttons to drive in a curve. On a laptop you can use the arrow keys or Q W E / A S D / Z X C.
- Move the Speed slider: slow is easier when you steer only by the video. Drag the Light slider up when it's dark under the furniture.
- Tap Take a photo. The picture opens in a new tab, and you can save it from there.
- Now drive only by the screen: turn your back to the car, or let a friend hide an object in the next room and go and find it.
If the car drives backwards when you press ▲, or turns the wrong way, one motor is wired the other way round. You can swap that motor's two wires, or set REVERSE_LEFT or REVERSE_RIGHT to true and upload again.
What you should see

How it works

The OV2640 camera can squeeze its pictures into JPEG files by itself, so the ESP32 doesn't have to do any image work. The sketch keeps two picture buffers in the board's extra memory (PSRAM): while one picture is sent, the camera already fills the other. A 640 × 480 picture is only about 10 to 30 KB, so the car can send 10 to 20 of them a second.
A video stream on a web page is surprisingly simple: the page at :81/stream never finishes loading. It is an endless list of JPEG pictures, each with a short header in front, and the browser shows every new picture in place of the old one. This is called MJPEG (motion JPEG), and many security cameras work the same way. Because sending pictures never stops, the video gets its own little web server on port 81. The buttons use a second web server on port 80, so they always get through, even while the video is running.
The car steers like a tank: it has no steering wheel, just two wheels that can run at different speeds. Both forwards means straight on, one forwards and one backwards means turning on the spot, and one fast and one slow means a curve. The table MIX holds the power of each wheel for each of the 9 buttons. For each wheel, the L293D gets two signals: power on IN1 means forwards, power on IN2 means backwards. The sketch sends PWM on one of them: the longer each pulse, the faster the motor turns. Every 20 ms the sketch changes the power by at most 10 %, so the car starts and stops smoothly instead of jerking.
While you hold a button, the page sends it to the car again every 150 ms. If nothing arrives for half a second, because you let go, the phone went to sleep or the car drove out of Wi-Fi range, the car stops by itself. Real remote-controlled machines have the same safety idea, often called a dead man's switch: a machine that loses contact must never keep driving.
Try this next
- Mount the camera on a servo, as in the robot arm, and add a slider to look left and right without turning the car. The ESP32-CAM has a free pin: IO2.
- Make a parking sensor: an ultrasonic sensor at the front that stops the car before it hits a wall, even if you keep pressing ▲.
- Hold a race: build an obstacle course with boxes and time each other. The rule: you may only look at the screen.
- Make a "spy mode" button that turns the light off and the picture smaller (QVGA), for a smoother video.
- Add a turn signal: a red LED on IO2 that blinks while the car turns.
Troubleshooting
- The upload doesn't start ("Failed to connect"): hold IO0 on the programmer, press and let go of RST, let go of IO0, and try again. Also try another USB cable: some cables only charge.
- "Camera not found" or the page says "No picture": the camera's ribbon cable is loose or in the wrong way round. Switch off, lift the latch, push the cable in fully with the gold contacts facing the board, close the latch and press RST.
- The ESP32-CAM keeps restarting ("Brownout detector was triggered"): it is not getting enough power. Use a good power bank and a short cable, check that no motor wire goes to the top rails, and that the motors get their own batteries.
-
The picture is there, but the wheels don't move: check that the battery holder is switched on, that the − rails are joined, and that L293D pins 1, 9 and 16 are on the top + rail. If the wheels only hum, make
SLOWESTbigger (try 160) or use fresh batteries. -
The car drives backwards or turns the wrong way: set
REVERSE_LEFTorREVERSE_RIGHTtotruefor the wheel that turns the wrong way. -
The picture is upside down: set
FLIP_PICTUREtotrueand upload again. -
The video is slow or freezes: stay closer to the car (the ESP32-CAM's antenna is small), use
FRAMESIZE_QVGA, or setPICTURE_QUALITYto 20. Only one phone can watch the video at a time; a second phone waits until the first one closes the page. - No CamRobotCar network appears: look at the Serial Monitor on the programmer. "Wi-Fi failed" means the password is shorter than 8 characters.
- The ESP32-CAM doesn't start while it is wired to the car, but works on the programmer: IO12 must be low when the board starts. Put a 10 kΩ resistor (ESP32 IoT Starter Kit) from L293D pin 10 to the − rail: it keeps IO12 low while the board starts.
- A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.