A fully wireless RC car controlled from any phone browser — no app install needed!
Built with ESP32 WROOM-32 · L298N Motor Driver · BO Motors · Touch Joystick Controller
This project turns a simple BO motor chassis into a WiFi-controlled RC car using an ESP32 microcontroller. The ESP32 creates its own WiFi hotspot — anyone can connect and drive it straight from their phone browser using a touch joystick. No app installation, no Bluetooth pairing, no internet required.
| # | Component | Details |
|---|---|---|
| 1 | ESP32 WROOM-32 | Main microcontroller, hosts WiFi hotspot + web server |
| 2 | L298N Motor Driver Module | Dual H-bridge, controls 2 BO motors |
| 3 | BO Motors | 2x Battery Operated gear motors |
| 4 | 11.8V Battery Pack | Powers L298N + ESP32 (via L298N 5V out) |
| 5 | Micro USB Cable | For uploading code (data cable, not charge-only) |
| 6 | Jumper Wires | Male-to-male for connections |
[11.8V Battery]
(+) ───────────────→ L298N [ +12V terminal ]
(−) ───────────────→ L298N [ GND terminal ]
│
┌──────────┘
│
├──→ ESP32 GND pin
L298N [ 5V terminal ] ───────→ ESP32 VIN pin
💡 One battery powers everything! The L298N's onboard 7805 regulator steps 11.8V down to 5V for the ESP32. Make sure the 12V jumper on L298N is ON to enable the onboard regulator.
ESP32 GPIO L298N Pin Function
──────────────────────────────────────────────────
GPIO 27 ───→ IN1 Motor A direction
GPIO 26 ───→ IN2 Motor A direction
GPIO 25 ───→ IN3 Motor B direction
GPIO 33 ───→ IN4 Motor B direction
GPIO 14 ───→ ENA Motor A speed (PWM)
GPIO 12 ───→ ENB Motor B speed (PWM)
GND ───→ GND Common ground ⚠️ REQUIRED
⚠️ Common Ground is critical! ESP32 signal levels are measured relative to its own GND. Without a shared GND between ESP32 and L298N, signals are unreadable and motors won't respond.
L298N OUT1 ───→ Motor 1 (+)
L298N OUT2 ───→ Motor 1 (−)
L298N OUT3 ───→ Motor 2 (+)
L298N OUT4 ───→ Motor 2 (−)
🔄 If a motor spins the wrong direction, simply swap its two wires on the OUT terminals.
┌─────────────────────────────────────────────────┐
│ │
│ Phone Browser ──HTTP──→ ESP32 Web Server │
│ (touch joystick) 192.168.4.1 │
│ │ │
│ L298N Module │
│ / \ │
│ Motor 1 Motor 2 │
│ │
└─────────────────────────────────────────────────┘
- ESP32 boots and creates a WiFi hotspot called
RC_CAR - Phone connects to the hotspot
- Browser opens
192.168.4.1 - ESP32 serves a web app with a touch joystick + speed slider
- Dragging the joystick sends HTTP commands (
/forward,/leftetc.) - ESP32 receives commands and drives motors via L298N
| Joystick Direction | Car Action |
|---|---|
| ⬆️ Drag Up | Forward |
| ⬇️ Drag Down | Backward |
| ⬅️ Drag Left | Turn Left |
| ➡️ Drag Right | Turn Right |
| 🔵 Release / Center | Stop |
| 🎚️ Speed Slider | Adjust motor speed (0–100%) |
Download from arduino.cc/en/software and install.
Go to File → Preferences → Additional Board Manager URLs and paste:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
Then go to Tools → Board → Board Manager, search esp32, and install esp32 by Espressif Systems.
Most ESP32 boards use one of these USB chips:
| Chip | Driver Download |
|---|---|
| CP2102 | silabs.com/developers/usb-to-uart-bridge-vcp-drivers |
| CH340 | wch-ic.com |
After install, check Device Manager → Ports for your COM port (e.g. COM3, COM4).
Tools → Board → ESP32 Dev Module
Tools → Port → COM3 (your port)
Tools → Upload Speed → 115200
- Open
motortest.inoin Arduino IDE - Click Verify (✓) — check for errors
- Hold the BOOT button on ESP32
- Click Upload (→)
- When
Connecting....appears — release BOOT - Wait for
Done uploading✅
💡 Some ESP32 boards auto-upload without holding BOOT — try without it first.
#include <WiFi.h>
#include <WebServer.h>
#define IN1 27
#define IN2 26
#define IN3 25
#define IN4 33
#define ENA 14
#define ENB 12
const char* ssid = "RC_CAR";
const char* password = "12345678";
WebServer server(80);
int currentSpeed = 200;
void setupPWM() {
ledcAttach(ENA, 1000, 8);
ledcAttach(ENB, 1000, 8);
}
void setSpeed(int s) { ledcWrite(ENA, s); ledcWrite(ENB, s); }
void forward() { digitalWrite(IN1,HIGH);digitalWrite(IN2,LOW);digitalWrite(IN3,HIGH);digitalWrite(IN4,LOW);setSpeed(currentSpeed); }
void backward() { digitalWrite(IN1,LOW);digitalWrite(IN2,HIGH);digitalWrite(IN3,LOW);digitalWrite(IN4,HIGH);setSpeed(currentSpeed); }
void turnLeft() { digitalWrite(IN1,LOW);digitalWrite(IN2,HIGH);digitalWrite(IN3,HIGH);digitalWrite(IN4,LOW);setSpeed(currentSpeed); }
void turnRight() { digitalWrite(IN1,HIGH);digitalWrite(IN2,LOW);digitalWrite(IN3,LOW);digitalWrite(IN4,HIGH);setSpeed(currentSpeed); }
void stopMotors(){ digitalWrite(IN1,LOW);digitalWrite(IN2,LOW);digitalWrite(IN3,LOW);digitalWrite(IN4,LOW);setSpeed(0); }
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>RC Car</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#111;color:#fff;font-family:Arial,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;overflow:hidden;user-select:none;}
h1{color:#00e5ff;font-size:20px;letter-spacing:3px;margin-bottom:6px;}
#status{font-size:24px;font-weight:bold;color:#fff;margin-bottom:20px;letter-spacing:2px;min-height:32px;}
#zone{width:220px;height:220px;border-radius:50%;background:#1a1a2e;border:2px solid #00e5ff;position:relative;touch-action:none;}
#stick{width:66px;height:66px;border-radius:50%;background:#00e5ff;position:absolute;top:77px;left:77px;pointer-events:none;}
.arr{position:absolute;color:#00e5ff;font-size:20px;}
.up{top:6px;left:50%;transform:translateX(-50%);}
.dn{bottom:6px;left:50%;transform:translateX(-50%);}
.lt{left:6px;top:50%;transform:translateY(-50%);}
.rt{right:6px;top:50%;transform:translateY(-50%);}
#speed-wrap{width:80%;margin:20px 0 8px;text-align:center;}
#speed-label{font-size:13px;color:#aaa;margin-bottom:6px;}
input[type=range]{width:100%;accent-color:#00e5ff;}
</style>
</head>
<body>
<h1>RC CAR</h1>
<div id="status">STOPPED</div>
<div id="zone">
<div id="stick"></div>
<span class="arr up">▲</span>
<span class="arr dn">▼</span>
<span class="arr lt">◄</span>
<span class="arr rt">►</span>
</div>
<div id="speed-wrap">
<div id="speed-label">Speed: 78%</div>
<input type="range" min="0" max="255" value="200" id="slider">
</div>
<script>
const zone=document.getElementById('zone');
const stick=document.getElementById('stick');
const statusEl=document.getElementById('status');
const slider=document.getElementById('slider');
const speedLabel=document.getElementById('speed-label');
const R=110,SR=33;
let lastCmd='',dragging=false;
function getCmd(x,y){
const d=Math.sqrt(x*x+y*y);
if(d<28)return 'stop';
if(Math.abs(x)>Math.abs(y))return x>0?'right':'left';
return y>0?'backward':'forward';
}
const labels={forward:'FORWARD',backward:'BACKWARD',left:'LEFT',right:'RIGHT',stop:'STOPPED'};
function applyPos(cx,cy){
const rect=zone.getBoundingClientRect();
let x=cx-rect.left-R,y=cy-rect.top-R;
const dist=Math.sqrt(x*x+y*y);
if(dist>R-SR){x=x/dist*(R-SR);y=y/dist*(R-SR);}
stick.style.left=(R+x-SR)+'px';
stick.style.top=(R+y-SR)+'px';
const cmd=getCmd(x,y);
statusEl.innerText=labels[cmd];
statusEl.style.color=cmd==='stop'?'#fff':'#00e5ff';
if(cmd!==lastCmd){lastCmd=cmd;fetch('/'+cmd);}
}
function resetStick(){
stick.style.left=(R-SR)+'px';
stick.style.top=(R-SR)+'px';
statusEl.innerText='STOPPED';
statusEl.style.color='#fff';
if(lastCmd!=='stop'){lastCmd='stop';fetch('/stop');}
}
zone.addEventListener('touchstart',e=>{e.preventDefault();dragging=true;applyPos(e.touches[0].clientX,e.touches[0].clientY);},{passive:false});
window.addEventListener('touchmove',e=>{if(dragging)applyPos(e.touches[0].clientX,e.touches[0].clientY);});
window.addEventListener('touchend',()=>{if(dragging){dragging=false;resetStick();}});
zone.addEventListener('mousedown',e=>{dragging=true;applyPos(e.clientX,e.clientY);});
window.addEventListener('mousemove',e=>{if(dragging)applyPos(e.clientX,e.clientY);});
window.addEventListener('mouseup',()=>{if(dragging){dragging=false;resetStick();}});
slider.addEventListener('input',function(){
const pct=Math.round(this.value/255*100);
speedLabel.innerText='Speed: '+pct+'%';
fetch('/speed?val='+this.value);
});
</script>
</body>
</html>
)rawliteral";
void setup() {
Serial.begin(115200);
pinMode(IN1,OUTPUT);pinMode(IN2,OUTPUT);
pinMode(IN3,OUTPUT);pinMode(IN4,OUTPUT);
setupPWM();
stopMotors();
WiFi.softAP(ssid, password);
Serial.print("Hotspot IP: ");
Serial.println(WiFi.softAPIP());
server.on("/", [](){server.send(200,"text/html",index_html);});
server.on("/forward", [](){forward(); server.send(200,"text/plain","ok");});
server.on("/backward",[](){backward(); server.send(200,"text/plain","ok");});
server.on("/left", [](){turnLeft(); server.send(200,"text/plain","ok");});
server.on("/right", [](){turnRight(); server.send(200,"text/plain","ok");});
server.on("/stop", [](){stopMotors();server.send(200,"text/plain","ok");});
server.on("/speed", [](){
if(server.hasArg("val"))currentSpeed=server.arg("val").toInt();
server.send(200,"text/plain","ok");
});
server.begin();
Serial.println("Server started!");
}
void loop(){ server.handleClient(); }Step 1 → Power on the car (battery connected)
Step 2 → On your phone, go to WiFi Settings
Step 3 → Connect to "RC_CAR"
Step 4 → Password: "12345678"
Step 5 → Open browser → type 192.168.4.1
Step 6 → Drag the joystick and drive! 🚗
📶 WiFi Range: ~10–30 metres open space. Perfect for indoors, hallways, and garden use.
| Problem | Cause | Fix |
|---|---|---|
| Port not showing in Arduino IDE | Wrong cable or missing driver | Use a data cable, install CP2102/CH340 driver |
Failed to connect upload error |
ESP32 not in flash mode | Hold BOOT button while clicking Upload |
| Motors don't move | Missing common ground | Connect L298N GND → ESP32 GND |
| Motors spin wrong direction | Wires reversed | Swap OUT1/OUT2 or OUT3/OUT4 wires |
| L298N gets very hot | High voltage + load | Add heatsink to L298N chip |
| Web page doesn't load | Wrong IP | Check Serial Monitor for actual IP |
ledcSetup compile error |
Old code on new ESP32 core v3.x | Use ledcAttach(pin, freq, res) instead |
Voltage is always relative — ESP32 signals are measured against its own GND. Without a shared GND, L298N has no reference point and misreads all signals. One wire between GND pins = everything works.
ledcWrite() sends a PWM signal (rapid on/off pulses) to ENA/ENB. The duty cycle (0–255) controls average voltage to motors, which translates directly to speed.
Android Chrome blocks gyroscope access on plain HTTP pages for security. ESP32 hotspot serves HTTP, so gyroscope is unavailable on Android. The touch joystick works perfectly as an alternative with zero setup needed.
The L298N's onboard 7805 regulator converts battery voltage (6–12V) to a stable 5V output — enough to power the ESP32 via its VIN pin, making the whole build run off a single battery pack.
esp32-rc-car/
│
├── motortest/
│ └── motortest.ino ← Main Arduino sketch
│
└── README.md ← This file
- Arduino IDE — Code editor and uploader
- ESP32 Arduino Core v3.x — ESP32 board support
- HTML / CSS / JavaScript — Onboard web controller UI
MIT License — free to use, modify, and share!
Made with ❤️ and a lot of wire
If this helped you, give it a ⭐ on GitHub!