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

Pong, two buttons, on an ESP32-C3

A TFT paddle game on an ILI9341 screen, full screen at forty frames a second, played with two buttons. The whole build, an ESP32-C3 and 4 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 30 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $23. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Reading buttons with internal pull-ups
  • Moving a paddle at a fixed rate while a button is held
  • Bouncing a ball and changing its angle by where it hits
  • Erasing only the part of the screen that changed
  • Timing a game loop at 40 frames a second
  • Difficulty: Advanced
  • Time: About 30 minutes
  • Board: ESP32-C3-DevKitM-1
  • Parts: 6

How it works

An ESP32-C3 drives a 240x320 ILI9341 screen over SPI on pins 4 (SCK), 5 (MOSI), 6 (CS) and 7 (DC). Two buttons on pins 2 and 3 move the paddle left and right, and a piezo on pin 8 makes the sounds.

A yellow ball bounces around a 180x220 playfield. Hold a button to slide the paddle. Hitting the ball with it scores a point, and where it lands on the paddle decides which way the ball leaves.

You have 3 lives. Miss the ball and you lose one. At zero the screen shows GAME OVER, and pressing either button starts a new game.

Parts list

6 parts, plus the jumper wires. They retail for about $23; on Mokxi they're free.

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 pinConnects to
TFT display, 240x320 pin VCCESP32-C3-DevKitM-1 pin 3V3
TFT display, 240x320 pin RSTESP32-C3-DevKitM-1 pin 3V3
TFT display, 240x320 pin LEDESP32-C3-DevKitM-1 pin 3V3
Pushbutton (1) pin 1aESP32-C3-DevKitM-1 pin 2
Pushbutton (2) pin 1aESP32-C3-DevKitM-1 pin 3
TFT display, 240x320 pin GNDESP32-C3-DevKitM-1 pin GND
Pushbutton (1) pin 2aESP32-C3-DevKitM-1 pin GND
Pushbutton (2) pin 2aESP32-C3-DevKitM-1 pin GND
Piezo speaker pin 2ESP32-C3-DevKitM-1 pin GND
TFT display, 240x320 pin SCKESP32-C3-DevKitM-1 pin 4
TFT display, 240x320 pin MOSIESP32-C3-DevKitM-1 pin 5
TFT display, 240x320 pin CSESP32-C3-DevKitM-1 pin 6
TFT display, 240x320 pin DCESP32-C3-DevKitM-1 pin 7
Piezo speaker pin 1ESP32-C3-DevKitM-1 pin 8

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
// Pong: a 2.4 inch ILI9341 and two buttons, on an ESP32-C3.
//
// Wiring, panel to board:
//   CS   -> GPIO 6      SCK  -> GPIO 4
//   DC   -> GPIO 7      MOSI -> GPIO 5
//   RST  -> 3V3         LED  -> 3V3      VCC -> 3V3, GND -> GND
// Buttons, each from its pin to ground (the pull-up is inside the chip):
//   left -> GPIO 2      right -> GPIO 3
// Piezo:
//   one leg -> GPIO 8, the other -> GND
//
// Hold a button to slide the paddle. Press either one to serve again after a
// game. Three lives; the score is how many times you sent the ball back.
//
// # Two buttons, not a stick
//
// This sketch was written when the Mokxi ESP32-C3 had no ADC and a
// potentiometer could not be read on this board at all. ADC1 is modelled now
// (docs/esp32c3.md section 3.5) and `stick` and `oledpong` here both read a
// thumb stick, so the buttons are a choice rather than a limit: a held button
// is a rate and a stick is a position, and a full-screen paddle game plays
// perfectly well on a rate. The Uno version of this example is the one with
// the stick on it.
//
// # Nothing is painted that is not used
//
// Every bit of every pixel is a pin change made by hand (there is no SPI
// peripheral either), and at 160 MHz with the direct port writes in
// mokxi_spi.h this board makes about a million of them a second. A full 240x320
// fill is 2.4 million pin changes, and while the chip would do it in eighty
// milliseconds the simulator has to work through every one of them, so it is
// the one thing in this sketch worth not doing.
//
// The controller's memory powers up black, so the sketch paints its playfield
// and its labels and leaves the rest of the panel alone. The playfield is
// 180x220, larger and twice as smooth as the 160x200 the Uno version can
// afford, which is the difference between the two boards in one number.

#include "mokxi_ili9341.h"
#include "mokxi_tone.h"

const uint8_t TFT_SCK = 4;
const uint8_t TFT_MOSI = 5;
const uint8_t TFT_CS = 6;
const uint8_t TFT_DC = 7;
const uint8_t PIEZO_PIN = 8;
const uint8_t LEFT_PIN = 2;
const uint8_t RIGHT_PIN = 3;

SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);

// The playfield is most of the panel, with a band above and below it.
const int16_t FX = 30;
const int16_t FY = 60;
const int16_t FW = 180;
const int16_t FH = 220;

const uint16_t BG = 0x0841;  // a very dark blue, so "cleared" is visible
const uint16_t EDGE = ILI9341_CYAN;

const int16_t BALL = 10;
const int16_t PADDLE_W = 44;
const int16_t PADDLE_H = 10;
const int16_t PADDLE_Y = FY + FH - 18;
const int16_t PADDLE_STEP = 6;

const int FRAME_MS = 25;  // forty frames a second
const uint8_t LIVES = 3;

int16_t bx;
int16_t by;
int16_t bdx;
int16_t bdy;
int16_t px;
int16_t oldPx;
uint8_t lives;
int16_t score;
int16_t shownScore;
bool playing;
bool started;

static int16_t clampInt(int16_t v, int16_t lo, int16_t hi) {
  return v < lo ? lo : (v > hi ? hi : v);
}

static void drawFrame() {
  tft.text(FX - 12, 22, "MOKXI PONG", ILI9341_WHITE, ILI9341_BLACK, 3);
  tft.fillRect(FX, FY, FW, FH, BG);
  tft.rect(FX - 2, FY - 2, FW + 4, FH + 4, EDGE, 2);
  // One row, not two: the panel is 320 tall and a second line of 14-pixel
  // capitals would fall off the bottom of it.
  tft.text(FX, FY + FH + 10, "SCORE", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(FX + 110, FY + FH + 10, "LIVES", ILI9341_WHITE, ILI9341_BLACK, 2);
}

static void drawScore() {
  tft.number(FX + 64, FY + FH + 10, score, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
  shownScore = score;
}

static void drawLives() {
  tft.number(FX + 174, FY + FH + 10, lives, ILI9341_ORANGE, ILI9341_BLACK, 2, 1);
}

static void serve() {
  bx = FX + FW / 2 - BALL / 2;
  by = FY + 30;
  bdx = (random(2) == 0) ? -4 : 4;
  bdy = 4;
  tft.fillRect(bx, by, BALL, BALL, ILI9341_YELLOW);
}

static void startGame() {
  score = 0;
  shownScore = -1;
  lives = LIVES;
  playing = true;
  if (started) {
    // Only a restart needs the playfield wiped; the first one was just painted.
    tft.fillRect(FX, FY, FW, FH, BG);
  }
  started = true;
  drawScore();
  drawLives();
  px = FX + (FW - PADDLE_W) / 2;
  oldPx = px;
  tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, ILI9341_GREEN);
  serve();
}

void setup() {
  Serial.begin(115200);
  Serial.println("Mokxi ESP32-C3: pong on an ILI9341, two buttons for the paddle");
  pinMode(LEFT_PIN, INPUT_PULLUP);
  pinMode(RIGHT_PIN, INPUT_PULLUP);
  mokxiNoTone(PIEZO_PIN);
  randomSeed(micros());
  tft.begin(0);
  drawFrame();
  startGame();
}

void loop() {
  bool left = digitalRead(LEFT_PIN) == LOW;
  bool right = digitalRead(RIGHT_PIN) == LOW;

  if (!playing) {
    if (left || right) {
      startGame();
    }
    delay(FRAME_MS);
    return;
  }

  if (left != right) {
    px = clampInt((int16_t)(px + (left ? -PADDLE_STEP : PADDLE_STEP)), FX,
                  (int16_t)(FX + FW - PADDLE_W));
    if (px != oldPx) {
      // Erase only the strip that stopped being paddle, then redraw it. A step
      // is never wider than the paddle, so there is always an overlap to keep.
      if (px > oldPx) {
        tft.fillRect(oldPx, PADDLE_Y, (int16_t)(px - oldPx), PADDLE_H, BG);
      } else {
        tft.fillRect((int16_t)(px + PADDLE_W), PADDLE_Y, (int16_t)(oldPx - px), PADDLE_H, BG);
      }
      tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, ILI9341_GREEN);
      oldPx = px;
    }
  }

  int16_t nx = (int16_t)(bx + bdx);
  int16_t ny = (int16_t)(by + bdy);
  bool bounced = false;

  if (nx < FX) {
    nx = FX;
    bdx = (int16_t)-bdx;
    bounced = true;
  } else if (nx > FX + FW - BALL) {
    nx = (int16_t)(FX + FW - BALL);
    bdx = (int16_t)-bdx;
    bounced = true;
  }
  if (ny < FY) {
    ny = FY;
    bdy = (int16_t)-bdy;
    bounced = true;
  }

  if (bdy > 0 && ny + BALL >= PADDLE_Y && by + BALL < PADDLE_Y + PADDLE_H) {
    if (nx + BALL > px && nx < px + PADDLE_W) {
      ny = (int16_t)(PADDLE_Y - BALL);
      bdy = (int16_t)-bdy;
      // Where it hit decides which way it leaves, which is the whole game.
      int16_t offset = (int16_t)((nx + BALL / 2) - (px + PADDLE_W / 2));
      bdx = (int16_t)(offset / 6);
      if (bdx > 5) bdx = 5;
      if (bdx < -5) bdx = -5;
      if (bdx == 0) bdx = 1;
      score++;
      mokxiTone(PIEZO_PIN, NOTE_C6, 25);
      bounced = false;
    }
  }

  tft.fillRect(bx, by, BALL, BALL, BG);

  if (ny > FY + FH - BALL) {
    mokxiTone(PIEZO_PIN, NOTE_C4, 150);
    lives--;
    drawLives();
    if (lives == 0u) {
      playing = false;
      tft.text(FX + 6, FY + FH / 2 - 12, "GAME OVER", ILI9341_RED, BG, 3);
      Serial.print("game over, score ");
      Serial.println(score);
      delay(FRAME_MS);
      return;
    }
    serve();
    delay(300);
    return;
  }

  bx = nx;
  by = ny;
  tft.fillRect(bx, by, BALL, BALL, ILI9341_YELLOW);

  if (score != shownScore) {
    drawScore();
  }
  if (bounced) {
    mokxiTone(PIEZO_PIN, NOTE_G5, 18);
  }

  delay(FRAME_MS);
}

Code walkthrough

The key functions of the sketch, and what each one does.

startGame sketch.ino

Resets the score and gives you LIVES, which is 3. After the first game it wipes the playfield. Then it draws the score, lives and paddle, centers the paddle, and serves the ball.

static void startGame() {
  score = 0;
  shownScore = -1;
  lives = LIVES;
  playing = true;
  if (started) {
    // Only a restart needs the playfield wiped; the first one was just painted.
    tft.fillRect(FX, FY, FW, FH, BG);
  }
  started = true;
  drawScore();
  drawLives();
  px = FX + (FW - PADDLE_W) / 2;
  oldPx = px;
  tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, ILI9341_GREEN);
  serve();
}

serve sketch.ino

Puts the ball near the top middle of the playfield. It moves down at 4 pixels a frame and sideways at 4 pixels a frame, to the left or right chosen at random.

static void serve() {
  bx = FX + FW / 2 - BALL / 2;
  by = FY + 30;
  bdx = (random(2) == 0) ? -4 : 4;
  bdy = 4;
  tft.fillRect(bx, by, BALL, BALL, ILI9341_YELLOW);
}

loop sketch.ino

Each frame it reads both buttons. If the game is over, either button restarts it. Otherwise it moves the paddle by PADDLE_STEP, moves the ball, bounces it off walls and the top, and checks the paddle. A catch scores and angles the ball by where it landed. A miss costs a life.

void loop() {
  bool left = digitalRead(LEFT_PIN) == LOW;
  bool right = digitalRead(RIGHT_PIN) == LOW;

  if (!playing) {
    if (left || right) {
      startGame();
    }
    delay(FRAME_MS);
    return;
  }

  if (left != right) {
    px = clampInt((int16_t)(px + (left ? -PADDLE_STEP : PADDLE_STEP)), FX,
                  (int16_t)(FX + FW - PADDLE_W));
    if (px != oldPx) {
      // Erase only the strip that stopped being paddle, then redraw it. A step
      // is never wider than the paddle, so there is always an overlap to keep.
      if (px > oldPx) {
        tft.fillRect(oldPx, PADDLE_Y, (int16_t)(px - oldPx), PADDLE_H, BG);
      } else {
        tft.fillRect((int16_t)(px + PADDLE_W), PADDLE_Y, (int16_t)(oldPx - px), PADDLE_H, BG);
      }
      tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, ILI9341_GREEN);
      oldPx = px;
    }
  }

  // ... 67 more lines in the editor

clampInt sketch.ino

Keeps a number between a low and high limit. The sketch uses it so the paddle can never slide past the left or right edge of the playfield.

static int16_t clampInt(int16_t v, int16_t lo, int16_t hi) {
  return v < lo ? lo : (v > hi ? hi : v);
}

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The paddle moves the wrong way This project
    Swap the two button wires, or swap LEFT_PIN and RIGHT_PIN in the code.
  • The screen stays dark This project
    RST and LED must go to 3V3 with VCC. Check SCK, MOSI, CS and DC are on pins 4, 5, 6 and 7.
  • Nothing happens when both buttons are held This project
    The paddle only moves when exactly one button is pressed. Release one.
  • The screen stays white or black. TFT display
    Check the SPI wiring: CS, DC, RST, MOSI, SCK. Power the module at 3.3 V logic, or use level shifting on 5 V boards. Call begin() and set the rotation.
  • Slow drawing or garbled colors. TFT display
    Use hardware SPI pins and avoid redrawing the whole screen. Fill only changed areas. Confirm the library matches ILI9341 and the color order.
  • The input reads random HIGH and LOW when the button is not pressed. Pushbutton
    A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW.
  • The button seems always pressed, or never changes. Pushbutton
    A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
  • A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
    A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead.

Change it and keep it

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