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

| 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 |

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
- 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://pong.local, or the number it shows, such ashttp://192.168.1.57. - Keep the joystick still for a second while the page calibrates.
- 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.
- 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
.localnames. Type the number from the Serial Monitor instead, withhttp://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.