OLED pong on an ESP32-C3
Thumb-stick pong on an ESP32-C3 and an OLED: a twelve-bit ADC moves the paddle and the score is drawn in digits. The whole build, an ESP32-C3 and 2 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Advanced About 20 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $18.50. On Mokxi they're free.
What you'll learn
- Reading a thumb stick with a twelve-bit ADC
- Redrawing only what moved on a small OLED
- Fixed-point math: using sixteenths of a pixel
- Changing a bounce angle by where the ball lands
- How ESP32-C3 and Uno versions of one game differ
- Difficulty: Advanced
- Time: About 20 minutes
- Board: ESP32-C3-DevKitM-1
- Parts: 4
How it works
Push the thumb stick up or down and your paddle follows it. The ESP32-C3 reads the stick's Y axis on pin 0 with a twelve-bit ADC, giving 0 to 4095, and maps that onto the rows the paddle can sit on. The board plays the right paddle. First to nine wins.
The ball moves in sixteenths of a pixel so it can travel at shallow angles. Where it hits a paddle sets the angle it leaves at: the ends throw it steeply, the middle sends it flatter.
After each point the game waits. Press the stick button on pin 3 to serve. This board has room for a font, so the score is drawn as numbers: YOU on the left and C3 on the right.
Parts list
4 parts, plus the jumper wires. They retail for about $18.50; on Mokxi they're free.
- 1 × ESP32-C3-DevKitM-1In Handheld Console Kit, Desk Buddy Kit.
- 1 × Full-size breadboard
- 1 × OLED display, 128x64In Fight Stick Kit, Pocket Pet Kit, Slot Machine Kit.
- 1 × Thumb stickIn Claw Machine Kit, Spaceship Panel Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| OLED display, 128x64 pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Thumb stick pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Thumb stick pin SW | ESP32-C3-DevKitM-1 pin 3 |
| OLED display, 128x64 pin GND | ESP32-C3-DevKitM-1 pin GND |
| Thumb stick pin GND | ESP32-C3-DevKitM-1 pin GND |
| Thumb stick pin VRY | ESP32-C3-DevKitM-1 pin 0 |
| Thumb stick pin VRX | ESP32-C3-DevKitM-1 pin 1 |
| OLED display, 128x64 pin SDA | ESP32-C3-DevKitM-1 pin 4 |
| OLED display, 128x64 pin SCL | ESP32-C3-DevKitM-1 pin 5 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// OLED pong: a game of pong on a 128x64 SSD1306, played with a thumb stick,
// on an ESP32-C3.
//
// Wiring, board to modules:
// OLED SCL -> GPIO 5 SDA -> GPIO 4 VCC -> 3V3 GND -> GND
// stick VRY -> GPIO 0 SW -> GPIO 3 VCC -> 3V3 GND -> GND
//
// Push the stick up and down and the left paddle follows it outright, because a
// potentiometer is an absolute position and so is a paddle: there is no
// acceleration to fight and no key to hold. The board plays the right paddle.
// First to nine. Press the stick to serve again.
//
// # The stick works here now
//
// This example used to be an Uno's alone: a thumb stick is two potentiometers,
// and the ESP32-C3 model had no ADC to read one with. It has one now (ADC1,
// one-shot, on GPIO 0 to GPIO 4 (docs/esp32c3.md section 3.5), so analogRead
// on GPIO 0 is a real twelve-bit conversion of the voltage the stick's Y pot
// puts on the pin, and the paddle follows the stick rather than a button.
//
// The Uno version of this game is still there and still an Uno's, so the two
// can be opened side by side. Wire for wire they are the same circuit; the
// differences are all in this file.
//
// # Twelve bits, not ten
//
// An axis here is 0..4095 rather than 0..1023, so the sketch maps the range on
// to the 39 rows a paddle may start on with `* 5 >> 9` where the Uno uses
// `* 5 >> 7`. Both are the mapping to within a row without a divide.
//
// # The score is digits
//
// The Uno version draws the score as nine pips a side, because the 5x7 font and
// the routine that walks it are eleven hundred bytes of an ATmega328P's eight
// kilobyte budget, and a kilobyte of that budget is already the frame buffer.
// This board has sixteen kilobytes of budget and four hundred of RAM, so the
// font costs nothing worth counting and the score is two numerals on a line
// with the word they are keeping.
//
// # Painting only what moved
//
// A 128x64 monochrome frame is 1024 bytes and this board bit-bangs the bus at
// about 196 kbit/s, so sending all of it every frame would be forty
// milliseconds of pin changes for the kernel to carry. mokxi_ssd1306.h sends
// only the eight-column blocks of each page that changed since the last
// display(), and this sketch helps it: each frame rubs out the ball and the two
// paddles where they were, draws them where they are now, and touches nothing
// else. The court and the net are drawn once, the score only when it changes.
//
// # The ball
//
// Sixteenths of a pixel, so the ball can move at a shallow angle without the
// integer arithmetic snapping it to 45 degrees. Where it hits the paddle sets
// the angle it leaves at (the ends throw it steeply, the middle straight),
// which is the one rule that makes pong a game of placement rather than of
// reflexes.
#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"
const uint8_t SCL_PIN = 5;
const uint8_t SDA_PIN = 4;
const uint8_t VRY_PIN = 0;
const uint8_t SW_PIN = 3;
SoftI2c bus(SCL_PIN, SDA_PIN, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);
// The stick is read straight rather than through mokxi_joystick.h: this sketch
// wants one absolute axis and a button, not a centred, dead-zoned pair.
// The court: the whole panel below the score line.
const int16_t TOP = 10;
const int16_t BOTTOM = 63;
const int16_t LEFT = 0;
const int16_t RIGHT = 127;
const int16_t PADDLE_H = 14;
const int16_t PADDLE_W = 2;
const int16_t BALL = 3;
const int16_t PLAYER_X = 2;
const int16_t COMPUTER_X = RIGHT - 1 - PADDLE_W;
/** Sixteenths of a pixel: the ball's own units. */
const int16_t SUB = 16;
const uint8_t SUB_BITS = 4;
const uint8_t TARGET = 9;
/** The column the net runs down. */
const int16_t NET_X = 63;
int16_t playerY = TOP + 8;
int16_t computerY = TOP + 8;
int16_t drawnPlayerY = -1;
int16_t drawnComputerY = -1;
int16_t ballX = 64 * SUB;
int16_t ballY = 32 * SUB;
int16_t ballDX = 3 * SUB / 2;
int16_t ballDY = SUB / 2;
int16_t drawnBallX = -1;
int16_t drawnBallY = -1;
uint8_t playerScore = 0;
uint8_t computerScore = 0;
bool waiting = true;
/** How far a paddle may sit, so it never leaves the court. */
static int16_t clampPaddle(int16_t y)
{
if (y < TOP + 1) {
return (int16_t)(TOP + 1);
}
if (y > BOTTOM - PADDLE_H) {
return (int16_t)(BOTTOM - PADDLE_H);
}
return y;
}
static void drawCourt()
{
oled.clear();
// The net, and the line under the score.
oled.fillRect(NET_X, (int16_t)(TOP + 2), 1, (int16_t)(BOTTOM - TOP - 1), true);
oled.fillRect(LEFT, TOP, (int16_t)(RIGHT - LEFT + 1), 1, true);
}
/** The score, in the 5x7 font: YOU on the left, C3 on the right. */
static void drawScore()
{
oled.fillRect(0, 0, 128, 9, false);
oled.text(2, 1, "YOU");
oled.number(22, 1, (long)playerScore);
oled.number(99, 1, (long)computerScore);
oled.text(107, 1, "C3");
// A cue that the board is waiting for a serve: the net's top pixel blinks
// off, which costs nothing and is the only thing on the panel that moves.
oled.fillRect(NET_X, (int16_t)(TOP + 2), 1, 3, !waiting);
}
/** Rub out the moving things where they were, and draw them where they are. */
static void paint()
{
if (drawnPlayerY >= 0 && drawnPlayerY != playerY) {
oled.fillRect(PLAYER_X, drawnPlayerY, PADDLE_W, PADDLE_H, false);
}
if (drawnComputerY >= 0 && drawnComputerY != computerY) {
oled.fillRect(COMPUTER_X, drawnComputerY, PADDLE_W, PADDLE_H, false);
}
int16_t bx = (int16_t)(ballX >> SUB_BITS);
int16_t by = (int16_t)(ballY >> SUB_BITS);
if (drawnBallX >= 0 && (drawnBallX != bx || drawnBallY != by)) {
oled.fillRect(drawnBallX, drawnBallY, BALL, BALL, false);
// The net runs down the middle of the court, so rubbing the ball out there
// takes a piece of it with it. Put that piece back.
if (drawnBallX <= NET_X && drawnBallX + BALL > NET_X) {
oled.fillRect(NET_X, drawnBallY, 1, BALL, true);
}
}
oled.fillRect(PLAYER_X, playerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(COMPUTER_X, computerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(bx, by, BALL, BALL, true);
drawnPlayerY = playerY;
drawnComputerY = computerY;
drawnBallX = bx;
drawnBallY = by;
oled.display();
}
/** Put the ball back in the middle, heading at `dir`. */
static void serve(int16_t dir)
{
ballX = 64 * SUB;
ballY = (int16_t)(((TOP + BOTTOM) / 2) * SUB);
ballDX = (int16_t)(dir * 3 * SUB / 2);
ballDY = (int16_t)(((playerScore + computerScore) & 1) == 0 ? SUB / 2 : -SUB / 2);
drawnBallX = -1;
drawnBallY = -1;
}
/**
* The angle the ball leaves a paddle at: where along the paddle it struck, as a
* fraction either side of the middle, scaled to a sensible slope.
*/
static int16_t bounceAngle(int16_t by, int16_t paddleY)
{
int16_t centre = (int16_t)(paddleY + PADDLE_H / 2);
int16_t offset = (int16_t)(by + BALL / 2 - centre);
// The paddle is fourteen tall, so the offset runs about +/- 7; doubling it
// spans the slope the ball is allowed.
int16_t dy = (int16_t)(offset * 2);
if (dy > SUB + SUB / 2) {
dy = (int16_t)(SUB + SUB / 2);
}
if (dy < -(SUB + SUB / 2)) {
dy = (int16_t)(-(SUB + SUB / 2));
}
return dy;
}
void setup()
{
Serial.begin(115200);
Serial.println("Mokxi ESP32-C3: pong on a 128x64 OLED, played with a thumb stick");
pinMode(SW_PIN, INPUT_PULLUP);
if (!oled.begin()) {
Serial.println("no OLED acknowledged 0x3c");
return;
}
drawCourt();
drawScore();
paint();
}
void loop()
{
// The stick: Y counts down the screen, so pushing it up moves the paddle up.
// 0..4095 onto the 39 rows the paddle may start on. `* 5 >> 9` is that to
// within a row and stays in sixteen bits.
int16_t axis = (int16_t)analogRead(VRY_PIN);
playerY = clampPaddle((int16_t)(TOP + 1 + ((axis * 5) >> 9)));
if (waiting) {
paint();
if (digitalRead(SW_PIN) == LOW) {
waiting = false;
if (playerScore >= TARGET || computerScore >= TARGET) {
playerScore = 0;
computerScore = 0;
drawCourt();
}
serve(playerScore > computerScore ? -1 : 1);
drawScore();
// Let go of the switch before the next round listens for it.
while (digitalRead(SW_PIN) == LOW) {
delay(8);
}
}
delay(24);
return;
}
// The board's paddle: it chases the ball, but not at full speed, so a ball
// placed at the far end of its travel gets past it.
int16_t want = (int16_t)((ballY >> SUB_BITS) - PADDLE_H / 2);
if (want > computerY + 1) {
computerY = (int16_t)(computerY + 2);
} else if (want < computerY - 1) {
computerY = (int16_t)(computerY - 2);
}
computerY = clampPaddle(computerY);
ballX = (int16_t)(ballX + ballDX);
ballY = (int16_t)(ballY + ballDY);
// The top and bottom walls.
if (ballY < (TOP + 1) * SUB) {
ballY = (int16_t)((TOP + 1) * SUB);
ballDY = (int16_t)(-ballDY);
}
if (ballY > (BOTTOM - BALL) * SUB) {
ballY = (int16_t)((BOTTOM - BALL) * SUB);
ballDY = (int16_t)(-ballDY);
}
int16_t bx = (int16_t)(ballX >> SUB_BITS);
int16_t by = (int16_t)(ballY >> SUB_BITS);
// The player's paddle.
if (ballDX < 0 && bx <= PLAYER_X + PADDLE_W && bx + BALL >= PLAYER_X) {
if (by + BALL > playerY && by < playerY + PADDLE_H) {
ballX = (int16_t)((PLAYER_X + PADDLE_W) * SUB);
ballDX = (int16_t)(-ballDX);
ballDY = bounceAngle(by, playerY);
}
}
// The board's.
if (ballDX > 0 && bx + BALL >= COMPUTER_X && bx <= COMPUTER_X + PADDLE_W) {
if (by + BALL > computerY && by < computerY + PADDLE_H) {
ballX = (int16_t)((COMPUTER_X - BALL) * SUB);
ballDX = (int16_t)(-ballDX);
ballDY = bounceAngle(by, computerY);
}
}
// Off the end: a point, and back to waiting.
if (bx < LEFT) {
computerScore++;
waiting = true;
} else if (bx + BALL > RIGHT) {
playerScore++;
waiting = true;
}
if (waiting) {
Serial.println(bx < LEFT ? "point to the board" : "point to you");
serve(0);
ballDX = 0;
ballDY = 0;
drawScore();
}
paint();
delay(24);
}
Code walkthrough
The key functions of the sketch, and what each one does.
paint sketch.ino
Rubs out the paddles and ball where they were, draws them where they are now, and calls oled.display(). Nothing else is touched. The library only sends the blocks that changed, which keeps the slow software I2C bus fast enough for a game.
/** Rub out the moving things where they were, and draw them where they are. */
static void paint()
{
if (drawnPlayerY >= 0 && drawnPlayerY != playerY) {
oled.fillRect(PLAYER_X, drawnPlayerY, PADDLE_W, PADDLE_H, false);
}
if (drawnComputerY >= 0 && drawnComputerY != computerY) {
oled.fillRect(COMPUTER_X, drawnComputerY, PADDLE_W, PADDLE_H, false);
}
int16_t bx = (int16_t)(ballX >> SUB_BITS);
int16_t by = (int16_t)(ballY >> SUB_BITS);
if (drawnBallX >= 0 && (drawnBallX != bx || drawnBallY != by)) {
oled.fillRect(drawnBallX, drawnBallY, BALL, BALL, false);
// The net runs down the middle of the court, so rubbing the ball out there
// takes a piece of it with it. Put that piece back.
if (drawnBallX <= NET_X && drawnBallX + BALL > NET_X) {
oled.fillRect(NET_X, drawnBallY, 1, BALL, true);
}
}
oled.fillRect(PLAYER_X, playerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(COMPUTER_X, computerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(bx, by, BALL, BALL, true);
drawnPlayerY = playerY;
drawnComputerY = computerY;
drawnBallX = bx;
drawnBallY = by;
oled.display();
}drawScore sketch.ino
Clears the top strip, then draws YOU and the player's score on the left, and the board's score and C3 on the right, using the OLED's text font. It also blinks the net's top pixel off while the game is waiting for you to serve.
/** The score, in the 5x7 font: YOU on the left, C3 on the right. */
static void drawScore()
{
oled.fillRect(0, 0, 128, 9, false);
oled.text(2, 1, "YOU");
oled.number(22, 1, (long)playerScore);
oled.number(99, 1, (long)computerScore);
oled.text(107, 1, "C3");
// A cue that the board is waiting for a serve: the net's top pixel blinks
// off, which costs nothing and is the only thing on the panel that moves.
oled.fillRect(NET_X, (int16_t)(TOP + 2), 1, 3, !waiting);
}bounceAngle sketch.ino
Finds how far above or below the paddle's center the ball hit, doubles that to get a vertical speed, and limits it to a safe range. A center hit goes nearly straight, an edge hit goes steep.
/**
* The angle the ball leaves a paddle at: where along the paddle it struck, as a
* fraction either side of the middle, scaled to a sensible slope.
*/
static int16_t bounceAngle(int16_t by, int16_t paddleY)
{
int16_t centre = (int16_t)(paddleY + PADDLE_H / 2);
int16_t offset = (int16_t)(by + BALL / 2 - centre);
// The paddle is fourteen tall, so the offset runs about +/- 7; doubling it
// spans the slope the ball is allowed.
int16_t dy = (int16_t)(offset * 2);
if (dy > SUB + SUB / 2) {
dy = (int16_t)(SUB + SUB / 2);
}
if (dy < -(SUB + SUB / 2)) {
dy = (int16_t)(-(SUB + SUB / 2));
}
return dy;
}loop sketch.ino
Each frame reads the stick with analogRead and sets your paddle using * 5 >> 9. While waiting, it only repaints and checks the button. Otherwise the board's paddle chases the ball, the ball moves and bounces, and a ball past an edge scores a point.
void loop()
{
// The stick: Y counts down the screen, so pushing it up moves the paddle up.
// 0..4095 onto the 39 rows the paddle may start on. `* 5 >> 9` is that to
// within a row and stays in sixteen bits.
int16_t axis = (int16_t)analogRead(VRY_PIN);
playerY = clampPaddle((int16_t)(TOP + 1 + ((axis * 5) >> 9)));
if (waiting) {
paint();
if (digitalRead(SW_PIN) == LOW) {
waiting = false;
if (playerScore >= TARGET || computerScore >= TARGET) {
playerScore = 0;
computerScore = 0;
drawCourt();
}
serve(playerScore > computerScore ? -1 : 1);
drawScore();
// Let go of the switch before the next round listens for it.
while (digitalRead(SW_PIN) == LOW) {
delay(8);
}
}
delay(24);
return;
}
// ... 62 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The paddle barely moves or jumps too far This project
The shift>> 9matches a 0 to 4095 reading. An axis of 0 to 1023 would need>> 7, as on the Uno. - The screen stays blank This project
Check SDA on pin 4, SCL on pin 5, and the 3V3 and GND wires. Serial prints a message if the OLED does not answer. - The OLED stays blank. OLED display
Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address. - Text never shows up even though the code runs. OLED display
Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames. - The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts. - The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference. - Upload fails or the board is not found by the computer. ESP32-C3-DevKitM-1
Pick the ESP32-C3 board in your tools and the right port. If it still fails, hold BOOT, tap RESET, then release BOOT to enter download mode and try again. - GPIO pins act strangely or the board resets. ESP32-C3-DevKitM-1
GPIO is 3.3 V only, so never feed it 5 V. GPIO 18 and 19 are USB, and GPIO 2, 8 and 9 are boot strapping pins. Avoid loading them at reset.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.