Most phone games are controlled with your fingers on the glass. This one has a real controller: the joystick on your breadboard. The ESP32 runs a small web server that sends a racing game to any phone, tablet or laptop on your Wi-Fi. Then it measures the joystick about 30 times a second and pushes the values straight into the game over a WebSocket. Push up to accelerate, steer left and right, and press the stick to restart. Drive onto the grass and you slow down; cross the line for a lap time. How fast can you go?
What you'll learn
- How the ESP32 serves a game page and streams live data to it over a WebSocket
- How the game finds the joystick's middle position by itself (calibration)
- How a simple car physics model works: speed, steering and friction
- How a game counts laps fairly with a checkpoint
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- Joystick module, KY-023 (ESP32 IoT Starter Kit)
- A phone, tablet or computer on the same Wi-Fi
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| Joystick GND | GND |
| Joystick +5V | 3V3 (the ESP32 can only measure up to 3.3 V) |
| Joystick VRx | ESP32 GPIO 34 |
| Joystick VRy | ESP32 GPIO 35 |
| Joystick SW | ESP32 GPIO 32 |

The sketch
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
In the Arduino IDE, open Library Manager and install WebSockets by Markus Sattler (in the Library Manager, search for "WebSockets" and pick the one by Markus Sattler).
WebServer and ESPmDNS come with the ESP32 board package.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <WebSocketsServer.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int X_PIN = 34, Y_PIN = 35, BUTTON_PIN = 32;
WebServer server(80); // serves the game page
WebSocketsServer sockets(81); // streams the joystick to the page
unsigned long lastSend = 0;
const char PAGE[] PROGMEM = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Joystick racer</title>
<style>body{margin:0;background:#1B3A7B;color:#fff;font-family:Arial,sans-serif;text-align:center}
canvas{width:100%;max-width:900px;display:block;margin:0 auto;background:#3C9E4A;touch-action:none}
#info{padding:8px;font-size:18px}</style></head><body>
<div id="info">Connecting...</div><canvas id="c" width="800" height="500"></canvas>
<script>
var c = document.getElementById('c'), g = c.getContext('2d'), info = document.getElementById('info');
var joy = { x: 0, y: 0, b: 0 }, cal = null, samples = [];
var car, lap, best = 0, laps = 0, half = false, lastX;
var CX = 400, CY = 250, L = 200, R = 150, HALF_W = 48;
function reset() { car = { x: 330, y: CY + R, a: 0, v: 0 }; lap = 0; half = false; lastX = car.x; }
reset();
function connect() {
var s = new WebSocket('ws://' + location.hostname + ':81/');
s.onopen = function () { info.textContent = 'Connected. Keep the joystick still for a moment...'; };
s.onclose = function () { info.textContent = 'Reconnecting...'; setTimeout(connect, 1000); };
s.onmessage = function (e) {
var v = e.data.split(',').map(Number);
if (!cal) { samples.push(v); if (samples.length === 15) { cal = [0, 1].map(function (i) { return samples.reduce(function (a, s) { return a + s[i]; }, 0) / 15; }); info.textContent = 'Go! Push up to drive.'; } return; }
joy.x = (v[0] - cal[0]) / 2000; joy.y = (v[1] - cal[1]) / 2000;
['x', 'y'].forEach(function (k) { if (Math.abs(joy[k]) < 0.1) joy[k] = 0; joy[k] = Math.max(-1, Math.min(1, joy[k])); });
if (v[2] && !joy.b) { reset(); }
joy.b = v[2];
};
}
connect();
function offTrack(x, y) {
var d = Math.abs(x - CX) <= L ? Math.abs(Math.abs(y - CY) - R) : Math.abs(Math.hypot(x - (x < CX ? CX - L : CX + L), y - CY) - R);
return d > HALF_W;
}
function track() {
g.lineWidth = HALF_W * 2; g.strokeStyle = '#5B5F66'; g.beginPath();
g.moveTo(CX - L, CY + R); g.lineTo(CX + L, CY + R); g.arc(CX + L, CY, R, Math.PI / 2, -Math.PI / 2, true);
g.lineTo(CX - L, CY - R); g.arc(CX - L, CY, R, -Math.PI / 2, Math.PI / 2, true); g.stroke();
g.lineWidth = 3; g.setLineDash([18, 14]); g.strokeStyle = '#fff'; g.stroke(); g.setLineDash([]);
for (var i = 0; i < 6; i++) { g.fillStyle = i % 2 ? '#fff' : '#111'; g.fillRect(CX - 4, CY + R - HALF_W + i * 16, 8, 16); }
}
var last = performance.now();
function frame(now) {
var dt = Math.min(0.05, (now - last) / 1000); last = now;
var throttle = -joy.y, grass = offTrack(car.x, car.y);
car.v += throttle * 260 * dt;
car.v *= grass ? 0.94 : 0.985;
var top = grass ? 90 : 300;
car.v = Math.max(-80, Math.min(top, car.v));
car.a += joy.x * 2.6 * dt * Math.min(1, Math.abs(car.v) / 80) * (car.v < 0 ? -1 : 1);
car.x += Math.cos(car.a) * car.v * dt; car.y += Math.sin(car.a) * car.v * dt;
car.x = Math.max(10, Math.min(790, car.x)); car.y = Math.max(10, Math.min(490, car.y));
if (cal) lap += dt;
if (car.y < CY && lastX >= CX && car.x < CX) half = true; // checkpoint on the far straight
if (car.y > CY && lastX < CX && car.x >= CX && half) { // crossed the line after the checkpoint
laps++; if (!best || lap < best) best = lap; lap = 0; half = false;
}
lastX = car.x;
g.fillStyle = '#3C9E4A'; g.fillRect(0, 0, 800, 500); track();
g.save(); g.translate(car.x, car.y); g.rotate(car.a);
g.fillStyle = '#DE544A'; g.fillRect(-18, -10, 36, 20); g.fillStyle = '#1B3A7B'; g.fillRect(2, -8, 10, 16);
g.fillStyle = '#111'; [[-14, -13], [8, -13], [-14, 9], [8, 9]].forEach(function (p) { g.fillRect(p[0], p[1], 8, 4); });
g.restore();
g.fillStyle = '#fff'; g.font = 'bold 20px Arial'; g.textAlign = 'center';
g.fillText('Lap ' + lap.toFixed(1) + ' s Best ' + (best ? best.toFixed(1) + ' s' : '-') + ' Laps ' + laps, 400, 250);
if (grass) { g.fillStyle = '#F2C94C'; g.fillText('On the grass!', 400, 280); }
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script></body></html>)rawliteral";
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
MDNS.begin("race"); // http://race.local
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.begin();
sockets.begin();
Serial.print("\nOpen http://race.local or http://");
Serial.println(WiFi.localIP());
}
void loop() {
server.handleClient();
sockets.loop();
if (millis() - lastSend >= 33) { // about 30 messages a second
lastSend = millis();
char msg[32];
snprintf(msg, sizeof(msg), "%d,%d,%d", analogRead(X_PIN), analogRead(Y_PIN), digitalRead(BUTTON_PIN) == LOW);
sockets.broadcastTXT(msg); // to every connected phone
}
}
Race!
- Upload the sketch and open the Serial Monitor at 115200 baud.
- Open the address from the Serial Monitor on your phone or computer, on the same Wi-Fi:
http://race.local, or the number it shows, such ashttp://192.168.1.57. - Keep the joystick still for a second while the page finds its middle position. Then push up to drive and steer left and right.
- Drive around the grey track in either direction and cross the chequered line to finish a lap. Press the joystick down to restart.
- Hand your phone to a friend and see who drives the best lap.
How it works
A normal web page asks the server for new data again and again. That is too slow for a game. A WebSocket is a connection that stays open, so the ESP32 can push a new message to the browser the moment it measures something, 30 or more times a second. The web page is stored in the sketch and served on port 80; the WebSocket runs next to it on port 81.
The ESP32 sends three numbers separated by commas, such as 1890,4095,0. The ESP32's analog pins measure from 0 to 4095, and a joystick's middle is usually somewhere around 1800 to 2000, different for every joystick. So the page averages the first 15 messages as the "middle" (calibration) and works with the difference from there.
The car has a position, a direction and a speed. In every frame, pushing up adds speed, friction removes a little, and the grass removes much more and limits the top speed. Steering turns the car's direction, but only while it is moving, just like a real car. A lap only counts if the car has passed the checkpoint on the far side of the track first, so you can't cheat by driving back and forth over the line.
Try this next
- Add a second joystick on GPIO 36 and 39 and race a friend: send five numbers instead of three, and draw two cars.
- Make the track narrower by changing
HALF_W, or design your own track shape. - Light an LED on the breadboard when you set a new best lap: let the page send a message back over the WebSocket, as in the Pong lesson.
Troubleshooting
-
race.local doesn't open: some phones don't understand
.localnames. Type the number from the Serial Monitor instead, withhttp://in front. - The car drives by itself: the joystick wasn't still while the page calibrated. Reload the page and keep your hands off for a second.
-
Steering is reversed: your joystick is turned round. Turn the breadboard, or change
joy.x = (v[0] - cal[0]) / 2000tojoy.x = (cal[0] - v[0]) / 2000. - "Connecting..." forever: the page opened, but the WebSocket on port 81 didn't. Check that the WebSockets library is the one by Markus Sattler.
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.