Play two-player Pong in the browser with real controllers

The classic first video game, rebuilt with the ESP32: player one steers with the joystick, player two turns a knob, and the game runs in any browser. The browser even talks back: every bounce makes the buzzer on the breadboard...
  • 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.

Play two-player Pong in the browser with real controllers – EEMENTOR STEM™ lesson

Pong, from 1972, was one of the first video games ever sold: two paddles, one ball, endless arguments about who won. In this lesson you rebuild it with two real controllers: the joystick for player one and a potentiometer knob for player two, just like the original Pong machines. The ESP32 streams both controllers to a game page in the browser. This time the connection works in both directions: when the ball bounces, the page sends a message back and the ESP32 plays the beep on the passive buzzer, so the sound comes from the breadboard, like a real arcade machine.

What you'll learn

  • How a WebSocket carries messages in both directions
  • Why a knob gives a position and a joystick gives a speed, and how the game uses both
  • How the ball bounces at different angles depending on where it hits the paddle
  • How to make sounds on the ESP32 from a web page

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • Joystick module, KY-023 (ESP32 IoT Starter Kit)
  • 10 kΩ potentiometer (ESP32 IoT Starter Kit)
  • Passive buzzer (ESP32 IoT Starter Kit)
  • A phone, tablet or computer on the same Wi-Fi, ideally a bigger screen
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Play two-player Pong in the browser with real controllers
Breadboard view. Check each connection against the wiring table below.
From To
Joystick GND, +5V GND, 3V3
Joystick VRy ESP32 GPIO 35
Potentiometer outer pins 3V3 and GND
Potentiometer middle pin ESP32 GPIO 34
Passive buzzer + ESP32 GPIO 33
Passive buzzer − GND
Example screen for: Play two-player Pong in the browser with real controllers
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).

#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 JOY_PIN = 35, KNOB_PIN = 34, BUZZER_PIN = 33;

WebServer server(80);
WebSocketsServer sockets(81);
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>Pong</title>
<style>body{margin:0;background:#000;color:#fff;font-family:Arial,sans-serif;text-align:center}
canvas{width:100%;max-width:960px;display:block;margin:0 auto;background:#0B1020}#info{padding:6px}</style></head><body>
<div id="info">Connecting...</div><canvas id="c" width="800" height="480"></canvas>
<script>
var c = document.getElementById('c'), g = c.getContext('2d'), info = document.getElementById('info');
var W = 800, H = 480, PH = 90, sock, cal = null, samples = [], joy = 0, knob = 0.5;
var p1 = H / 2, p2 = H / 2, ball, score = [0, 0], pause = 2, winner = -1;
function serve(dir) { ball = { x: W / 2, y: H / 2, vx: 300 * dir, vy: (Math.random() - 0.5) * 300 }; pause = 1.5; }
serve(1);
function send(m) { if (sock && sock.readyState === 1) sock.send(m); }
function connect() {
  sock = new WebSocket('ws://' + location.hostname + ':81/');
  sock.onopen = function () { info.textContent = 'Player 1: joystick (left) - Player 2: knob (right) - first to 7'; };
  sock.onclose = function () { info.textContent = 'Reconnecting...'; setTimeout(connect, 1000); };
  sock.onmessage = function (e) {
    var v = e.data.split(',').map(Number);
    if (!cal) { samples.push(v[0]); if (samples.length === 15) cal = samples.reduce(function (a, b) { return a + b; }, 0) / 15; return; }
    joy = (v[0] - cal) / 2000; if (Math.abs(joy) < 0.1) joy = 0;
    knob = v[1] / 4095;
  };
}
connect();
var last = performance.now();
function frame(now) {
  var dt = Math.min(0.04, (now - last) / 1000); last = now;
  p1 = Math.max(PH / 2, Math.min(H - PH / 2, p1 + joy * 600 * dt));     // the joystick sets a speed
  p2 = PH / 2 + (1 - knob) * (H - PH);                                    // the knob sets a position
  if (winner >= 0) { pause -= dt; if (pause <= 0) { score = [0, 0]; winner = -1; serve(1); } }
  else if (pause > 0) pause -= dt;
  else {
    ball.x += ball.vx * dt; ball.y += ball.vy * dt;
    if (ball.y < 8 || ball.y > H - 8) { ball.vy = -ball.vy; ball.y = Math.max(8, Math.min(H - 8, ball.y)); send('wall'); }
    [[30, p1, 1], [W - 30, p2, -1]].forEach(function (p) {
      if (Math.abs(ball.x - p[0]) < 10 && Math.abs(ball.y - p[1]) < PH / 2 + 8 && ball.vx * p[2] < 0) {
        var hit = (ball.y - p[1]) / (PH / 2);                             // -1 top edge ... +1 bottom edge
        var speed = Math.min(900, Math.hypot(ball.vx, ball.vy) * 1.06);
        ball.vx = p[2] * speed * Math.cos(hit * 0.9); ball.vy = speed * Math.sin(hit * 0.9);
        send('hit');
      }
    });
    if (ball.x < -20 || ball.x > W + 20) {
      var who = ball.x < 0 ? 1 : 0; score[who]++; send('point');
      if (score[who] >= 7) { winner = who; pause = 5; send('win'); } else serve(who ? -1 : 1);
    }
  }
  g.fillStyle = '#0B1020'; g.fillRect(0, 0, W, H);
  g.fillStyle = '#334'; for (var y = 0; y < H; y += 30) g.fillRect(W / 2 - 2, y, 4, 18);
  g.fillStyle = '#DE544A'; g.fillRect(22, p1 - PH / 2, 12, PH);
  g.fillStyle = '#4DB8FF'; g.fillRect(W - 34, p2 - PH / 2, 12, PH);
  g.fillStyle = '#fff'; g.fillRect(ball.x - 8, ball.y - 8, 16, 16);
  g.font = 'bold 56px monospace'; g.textAlign = 'center'; g.fillText(score[0], W / 2 - 80, 70); g.fillText(score[1], W / 2 + 80, 70);
  if (!cal) { g.font = '20px Arial'; g.fillText('Keep the joystick still...', W / 2, H / 2 + 60); }
  if (winner >= 0) { g.font = 'bold 40px Arial'; g.fillText('Player ' + (winner + 1) + ' wins!', W / 2, H / 2); }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script></body></html>)rawliteral";

// Messages from the browser: play the matching sound on the buzzer
void onSocket(uint8_t client, WStype_t type, uint8_t* payload, size_t length) {
  if (type != WStype_TEXT) return;
  String msg = String((char*)payload).substring(0, length);
  if (msg == "hit") tone(BUZZER_PIN, 880, 40);
  else if (msg == "wall") tone(BUZZER_PIN, 440, 30);
  else if (msg == "point") tone(BUZZER_PIN, 220, 300);
  else if (msg == "win") tone(BUZZER_PIN, 1047, 600);
}

void setup() {
  Serial.begin(115200);
  WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  MDNS.begin("pong");                    // http://pong.local
  server.on("/", []() { server.send_P(200, "text/html", PAGE); });
  server.begin();
  sockets.begin();
  sockets.onEvent(onSocket);
  Serial.print("\nOpen http://pong.local or http://");
  Serial.println(WiFi.localIP());
}

void loop() {
  server.handleClient();
  sockets.loop();
  if (millis() - lastSend >= 30) {
    lastSend = millis();
    char msg[24];
    snprintf(msg, sizeof(msg), "%d,%d", analogRead(JOY_PIN), analogRead(KNOB_PIN));
    sockets.broadcastTXT(msg);
  }
}

Play it

  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://pong.local, or the number it shows, such as http://192.168.1.57.
  3. Keep the joystick still for a second while the page calibrates.
  4. Player one moves the left paddle with the joystick (up and down). Player two turns the knob to move the right paddle. Every bounce beeps on the breadboard. First to 7 wins.
  5. Try hitting the ball with the edge of your paddle: it flies off at a steep angle.

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 two controllers work differently. The knob is an absolute controller: every position of the knob is one position of the paddle, like the original Pong. The joystick springs back to the middle, so it is a speed controller: the further you push, the faster the paddle moves, and when you let go it stops. Which one do you prefer?

The bounce angle depends on where the ball hits the paddle. The page works out a number from -1 (top edge) to +1 (bottom edge) and turns it into an angle with cosine and sine, so the ball keeps its speed but changes direction. Each hit also makes the ball 6 % faster, so long rallies get exciting.

Sound travels the other way. The page calls sock.send('hit'), the ESP32 receives the text in onSocket() and plays a tone. The same connection carries messages in both directions at once, which is called full duplex.

Try this next

  • Add a "serve" button on GPIO 32, so the ball only starts when a player presses it.
  • Let the ESP32 light a red LED for player one's points and a blue LED for player two's.
  • Add a computer player for when you are alone: let the right paddle follow the ball at a limited speed.

Troubleshooting

  • pong.local doesn't open: some phones don't understand .local names. Type the number from the Serial Monitor instead, with http:// in front.
  • Player two's paddle moves the wrong way: swap the knob's two outer wires.
  • No beeps: check that the buzzer is the passive one and that + goes to GPIO 33. Tap the page once: some browsers only open the WebSocket after the page is fully loaded.
  • 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