Race a car on your phone with the joystick

The ESP32 serves a racing game to your phone or laptop and streams the joystick to it live over Wi-Fi. Steer around the track, stay off the grass and beat your best lap time.
  • Level:Advanced
  • Duration: 1 h 15 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Race a car on your phone with the joystick – EEMENTOR STEM™ lesson

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

Breadboard wiring diagram: Race a car on your phone with the joystick
Breadboard view. Check each connection against the wiring table below.
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
Example screen for: Race a car on your phone with the joystick
Example screen with sample values. Yours may look different.

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!

  1. Upload the sketch and open the Serial Monitor at 115200 baud.
  2. 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 as http://192.168.1.57.
  3. Keep the joystick still for a second while the page finds its middle position. Then push up to drive and steer left and right.
  4. Drive around the grey track in either direction and cross the chequered line to finish a lap. Press the joystick down to restart.
  5. 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 .local names. Type the number from the Serial Monitor instead, with http:// 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]) / 2000 to joy.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.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit