Built-in project · ESP32-C3-DevKitM-1

OLED pong on an ESP32-C3

The thumb-stick pong, on the other board: 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.

AdvancedRuns in your browser. Free, and no account needed.

OLED pong on an ESP32-C3 · ESP32-C3-DevKitM-1live0.000 s 0.00x
Hold the button
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32-C3-DevKitM-1
// 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);
}

Parts list

4 parts, plus the jumper wires. Every one is in the editor's parts bin.

How it is wired

7 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • ESP32-C3-DevKitM-1 pin 3V3; OLED display, 128x64 pin VCC; Thumb stick pin VCC
  • ESP32-C3-DevKitM-1 pin 3; Thumb stick pin SW
  • Ground: ESP32-C3-DevKitM-1 pin GND; OLED display, 128x64 pin GND; Thumb stick pin GND
  • ESP32-C3-DevKitM-1 pin 0; Thumb stick pin VRY
  • ESP32-C3-DevKitM-1 pin 1; Thumb stick pin VRX
  • ESP32-C3-DevKitM-1 pin 4; OLED display, 128x64 pin SDA
  • ESP32-C3-DevKitM-1 pin 5; OLED display, 128x64 pin SCL

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.