Phone-Controlled ESP32 Car: drive it from any browser, no app needed
The ESP32 has Wi-Fi built in, so it can be its own hotspot and web server. Join its network from your phone, open one address, and you get arrow buttons and a speed slider.
1. What you need
The same car, L298N and wiring as the obstacle-avoiding car. The ultrasonic sensor can stay or go.
2. How it works
- Access point mode:
WiFi.softAP()creates a network called TinyBot-Car. Its address is always192.168.4.1. - Web server:
WebServerserves the controller page, which lives inside the sketch — no internet needed. - Commands: while you hold a button, the phone sends
/cmd?c=F(etc.) every 200 ms; releasing sends stop. - Failsafe: no command for 0.6 s and the car stops. If your phone drops the connection, the car won't run away.
3. Code
Change AP_PASS to your own password (at least 8 characters) before uploading.
/*
* TinyBot Forge — Phone-Controlled ESP32 Robot
* https://tinybotforge.com/en/
*
* The ESP32 creates its own Wi-Fi hotspot. Join it with your phone and open
* http://192.168.4.1 to drive. No router, no app.
* Hardware and wiring are the same as the ESP32 obstacle car (L298N + two TT motors).
* Toolchain: Arduino IDE 2.x + ESP32 board package 3.x
*/
#include <WiFi.h>
#include <WebServer.h>
// ---------- Hotspot settings (password: at least 8 characters, change it!) ----------
const char *AP_SSID = "TinyBot-Car";
const char *AP_PASS = "tinybot123";
// ---------- Motor pins (same as the obstacle car) ----------
const int PIN_ENA = 25, PIN_IN1 = 26, PIN_IN2 = 27;
const int PIN_ENB = 33, PIN_IN3 = 32, PIN_IN4 = 13;
const int PWM_FREQ = 1000, PWM_BITS = 8;
const unsigned long FAILSAFE_MS = 600; // stop if no command arrives for this long
WebServer server(80);
int speedValue = 180;
unsigned long lastCommandMs = 0;
void setMotor(int pinEn, int pinA, int pinB, int speed) {
speed = constrain(speed, -255, 255);
digitalWrite(pinA, speed > 0 ? HIGH : LOW);
digitalWrite(pinB, speed < 0 ? HIGH : LOW);
ledcWrite(pinEn, abs(speed));
}
void drive(int left, int right) {
setMotor(PIN_ENA, PIN_IN1, PIN_IN2, left);
setMotor(PIN_ENB, PIN_IN3, PIN_IN4, right);
}
// Controller page: sends a command every 200 ms while a button is held, and stop on release
const char PAGE[] PROGMEM = R"HTML(
<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>TinyBot Remote</title>
<style>
body{font-family:sans-serif;background:#1b1d1f;color:#eee;text-align:center;margin:0;padding:20px;user-select:none;-webkit-user-select:none}
.pad{display:grid;grid-template-columns:repeat(3,90px);gap:12px;justify-content:center;margin:24px auto}
button{height:90px;font-size:34px;border:0;border-radius:18px;background:#e8551c;color:#fff;touch-action:none}
button.stop{background:#555}
input{width:80%}
</style></head><body>
<h2>TinyBot Remote</h2>
<div class="pad">
<span></span><button data-c="F">▲</button><span></span>
<button data-c="L">◀</button><button class="stop" data-c="S">■</button><button data-c="R">▶</button>
<span></span><button data-c="B">▼</button><span></span>
</div>
<p>Speed <span id="v">180</span></p>
<input type="range" min="100" max="255" value="180" id="spd">
<script>
let timer=null;
function send(c){fetch('/cmd?c='+c+'&s='+document.getElementById('spd').value).catch(()=>{});}
function start(c){stop();send(c);if(c!=='S')timer=setInterval(()=>send(c),200);}
function stop(){if(timer){clearInterval(timer);timer=null;}}
document.querySelectorAll('button').forEach(b=>{
b.addEventListener('pointerdown',e=>{e.preventDefault();start(b.dataset.c);});
['pointerup','pointerleave','pointercancel'].forEach(t=>b.addEventListener(t,()=>{if(timer){stop();send('S');}}));
});
document.getElementById('spd').oninput=e=>document.getElementById('v').textContent=e.target.value;
</script></body></html>
)HTML";
void handleRoot() { server.send_P(200, "text/html", PAGE); }
void handleCommand() {
String c = server.arg("c");
if (server.hasArg("s")) speedValue = constrain(server.arg("s").toInt(), 100, 255);
int s = speedValue;
int turn = s * 3 / 4;
if (c == "F") drive(s, s);
else if (c == "B") drive(-s, -s);
else if (c == "L") drive(-turn, turn);
else if (c == "R") drive(turn, -turn);
else drive(0, 0);
lastCommandMs = millis();
server.send(200, "text/plain", "ok");
}
void setup() {
Serial.begin(115200);
pinMode(PIN_IN1, OUTPUT); pinMode(PIN_IN2, OUTPUT);
pinMode(PIN_IN3, OUTPUT); pinMode(PIN_IN4, OUTPUT);
ledcAttach(PIN_ENA, PWM_FREQ, PWM_BITS);
ledcAttach(PIN_ENB, PWM_FREQ, PWM_BITS);
drive(0, 0);
WiFi.softAP(AP_SSID, AP_PASS);
Serial.print("Hotspot ready. Join it and open http://");
Serial.println(WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/cmd", handleCommand);
server.begin();
}
void loop() {
server.handleClient();
// Failsafe: stop if the phone disconnects or switches apps
if (lastCommandMs != 0 && millis() - lastCommandMs > FAILSAFE_MS) {
drive(0, 0);
lastCommandMs = 0;
}
}
4. Drive it
- Upload and open Serial Monitor (115200). You'll see "Hotspot ready".
- On your phone, join the TinyBot-Car Wi-Fi.
- If the phone warns "no internet", choose stay connected.
- Open
http://192.168.4.1and hold the arrows.
5. Troubleshooting
192.168.4.1 won't load
Your phone may have switched back to mobile data. Turn mobile data off, and make sure the address starts with http://, not https.
The car stutters while a button is held
Weak signal or interference. Test within 3 m, or raise FAILSAFE_MS to 1000.
Left and right are swapped
Swap the wires of one motor, or swap the drive() arguments for L and R.
Can it use my home Wi-Fi instead?
Yes: replace WiFi.softAP() with WiFi.begin("ssid", "password") and read the IP from Serial Monitor. Hotspot mode just works anywhere.
Free: ESP32 Robot Wiring Cheat Sheet
One printable page: safe ESP32 pins, L298N, HC-SR04, servo wiring and the most common fixes.
Download the PDF