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

Console on an ESP32-C3

Snake and Breakout on a handheld cabinet: a 128x64 OLED over I2C and eight buttons to ground, with a menu on the D-pad. The whole build, an ESP32-C3 and 1 more part, 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 $13. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Reading buttons wired to ground with pull-ups
  • Driving an I2C OLED from a microcontroller
  • A screen state machine: menu, game, game over
  • Redrawing only what moved
  • Seeding random numbers from a person's first button press
  • Difficulty: Advanced
  • Time: About 30 minutes
  • Board: ESP32-C3-DevKitM-1
  • Parts: 3

How it works

The handheld cabinet is a 128x64 OLED on two I2C wires (SDA on pin 5, SCL on pin 6) plus eight buttons, each wired from a pin to ground. The sketch reads the buttons as pull-up inputs, so a press reads as a pressed pad. It starts on a menu with two games.

Up or down moves the arrow between Snake and Breakout. A or Start plays, and Select backs out to the menu. When a game ends, a score screen shows and A plays the same game again.

Neither game redraws the whole screen. Snake erases its tail cell and lights a new head. Breakout erases the ball and paddle where they were and draws them where they are. That keeps each frame small and fast.

Parts list

3 parts, plus the jumper wires. They retail for about $13; 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
Handheld console pin VCCESP32-C3-DevKitM-1 pin 3V3
Handheld console pin SELECTESP32-C3-DevKitM-1 pin 2
Handheld console pin STARTESP32-C3-DevKitM-1 pin 3
Handheld console pin GNDESP32-C3-DevKitM-1 pin GND
Handheld console pin RIGHTESP32-C3-DevKitM-1 pin 0
Handheld console pin LEFTESP32-C3-DevKitM-1 pin 1
Handheld console pin DOWNESP32-C3-DevKitM-1 pin 10
Handheld console pin UPESP32-C3-DevKitM-1 pin 4
Handheld console pin SDAESP32-C3-DevKitM-1 pin 5
Handheld console pin SCLESP32-C3-DevKitM-1 pin 6
Handheld console pin BESP32-C3-DevKitM-1 pin 7
Handheld console pin AESP32-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
// Console: two games on the handheld cabinet: Snake and Breakout.
//
// Wiring, board to the cabinet's twelve-pin header:
//   VCC -> 3V3   GND -> GND   SCL -> GPIO 6   SDA -> GPIO 5
//   UP -> 4   DOWN -> 10   LEFT -> 1   RIGHT -> 0
//   A -> 8   B -> 7   START -> 3   SELECT -> 2
//
// Ten signal pins, all on the module's left-hand header, so the cabinet stands
// square above it and most of the leads are a straight drop.
//
// The cabinet is nothing new: behind the bezel is the same SSD1306 panel the
// OLED examples drive, at the same address on the same two wires, and the eight
// buttons are eight contacts to the cabinet's ground. So this sketch is an OLED
// driver and eight INPUT_PULLUP pins, and it would run unchanged on a loose
// panel with eight buttons on a breadboard.
//
// Click the cabinet on the canvas while this is running: the arrow keys work
// the D-pad, Z is A, X is B, Enter is Start and Shift is Select. Escape hands
// the keyboard back to the editor.
//
// # The menu
//
// Up and down choose, A or Start plays. Select gets out of a game and back
// here, and after a game is over A plays it again.
//
// # Painting only what moved
//
// A 128x64 frame is 1024 bytes and this board bit-bangs the bus, so redrawing
// everything each frame would be forty milliseconds of pin changes. Neither
// game clears the screen: Snake rubs out its tail cell and draws its new head,
// Breakout rubs out the ball and the paddle where they were and draws them
// where they are. mokxi_ssd1306.h sends only the eight-column blocks that
// changed, so a frame costs a few dozen bytes.
//
// # Where the random numbers come from
//
// Seeding in setup() would give the same game every run, because nothing on the
// board differs between one boot and the next. The generator is seeded from
// micros() at the first button press instead: that is a person's reaction time,
// and nothing here can work it out in advance.

#include "mokxi_gamepad.h"
#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"

const uint8_t SCL_PIN = 6;
const uint8_t SDA_PIN = 5;

SoftI2c bus(SCL_PIN, SDA_PIN, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);
/* up, down, left, right, A, B, Start, Select */
Cabinet pad(4, 10, 1, 0, 8, 7, 3, 2);

enum Screen : uint8_t { MENU, SNAKE, BRICKS, OVER };
Screen screen = MENU;

/** Which game the menu is pointing at, and which one is over. */
uint8_t choice = 0;
const char *overTitle = "";
uint16_t overScore = 0;

bool seeded = false;

// ------------------------------------------------------------------ the field

/** The play area starts under a ten-pixel status strip. */
const int16_t FIELD_TOP = 11;
const int16_t FIELD_BOTTOM = 63;

static void statusLine(const char *left, uint16_t value)
{
  oled.fillRect(0, 0, 128, 10, false);
  int16_t x = oled.text(0, 1, left);
  oled.number((int16_t)(x + 2), 1, (long)value);
  oled.hLine(0, FIELD_TOP - 1, 128, true);
}

// ------------------------------------------------------------------- the menu

static void drawMenu()
{
  oled.clear();
  oled.text(22, 2, "MOKXI", 2);
  oled.text(16, 30, "SNAKE");
  oled.text(16, 44, "BREAKOUT");
  oled.text(4, (int16_t)(choice == 0 ? 30 : 44), ">");
  oled.text(0, 56, "A or Start to play");
  oled.display();
}

// ------------------------------------------------------------------ the snake

const int16_t CELL = 4;
const uint8_t GRID_W = 32;             // 128 / 4
const uint8_t GRID_H = 13;             // (64 - 12) / 4
const int16_t GRID_Y = FIELD_TOP + 1;  // the first row's top edge

const uint8_t SNAKE_MAX = 120;
uint8_t snakeX[SNAKE_MAX];
uint8_t snakeY[SNAKE_MAX];
uint8_t snakeHead = 0;   // index of the head in the ring
uint8_t snakeLen = 0;
int8_t snakeDX = 1;
int8_t snakeDY = 0;
uint8_t foodX = 0;
uint8_t foodY = 0;
uint16_t snakeScore = 0;
uint16_t snakeStep = 140;
uint32_t snakeLast = 0;

static void cell(uint8_t cx, uint8_t cy, bool on)
{
  oled.fillRect((int16_t)cx * CELL, (int16_t)(GRID_Y + cy * CELL), CELL - 1, CELL - 1, on);
}

/** Is this cell part of the snake right now? */
static bool onSnake(uint8_t cx, uint8_t cy)
{
  for (uint8_t i = 0; i < snakeLen; i++) {
    uint8_t at = (uint8_t)((snakeHead + SNAKE_MAX - i) % SNAKE_MAX);
    if (snakeX[at] == cx && snakeY[at] == cy) {
      return true;
    }
  }
  return false;
}

static void dropFood()
{
  for (uint8_t tries = 0; tries < 200; tries++) {
    uint8_t cx = (uint8_t)random(GRID_W);
    uint8_t cy = (uint8_t)random(GRID_H);
    if (!onSnake(cx, cy)) {
      foodX = cx;
      foodY = cy;
      cell(foodX, foodY, true);
      return;
    }
  }
}

static void startSnake()
{
  oled.clear();
  snakeLen = 3;
  snakeHead = 2;
  for (uint8_t i = 0; i < snakeLen; i++) {
    snakeX[i] = (uint8_t)(4 + i);
    snakeY[i] = (uint8_t)(GRID_H / 2);
    cell(snakeX[i], snakeY[i], true);
  }
  snakeDX = 1;
  snakeDY = 0;
  snakeScore = 0;
  snakeStep = 140;
  snakeLast = millis();
  statusLine("SNAKE", snakeScore);
  dropFood();
  oled.display();
  screen = SNAKE;
}

static void snakeOver()
{
  overTitle = "SNAKE";
  overScore = snakeScore;
  screen = OVER;
}

static void stepSnake()
{
  int8_t dx = pad.dx();
  int8_t dy = pad.dy();
  // A snake cannot turn back on itself, and a diagonal is not a direction: the
  // D-pad's up and down win over its left and right when both arrive.
  if (dy != 0 && snakeDY == 0) {
    snakeDX = 0;
    snakeDY = dy;
  } else if (dx != 0 && snakeDX == 0) {
    snakeDX = dx;
    snakeDY = 0;
  }

  if ((uint32_t)(millis() - snakeLast) < (uint32_t)snakeStep) {
    return;
  }
  snakeLast = millis();

  int16_t nx = (int16_t)snakeX[snakeHead] + snakeDX;
  int16_t ny = (int16_t)snakeY[snakeHead] + snakeDY;
  if (nx < 0 || ny < 0 || nx >= (int16_t)GRID_W || ny >= (int16_t)GRID_H) {
    snakeOver();
    return;
  }
  if (onSnake((uint8_t)nx, (uint8_t)ny)) {
    snakeOver();
    return;
  }

  bool ate = (uint8_t)nx == foodX && (uint8_t)ny == foodY;
  if (!ate) {
    // The tail cell is the one that goes out; the head is the one that lights.
    uint8_t tail = (uint8_t)((snakeHead + SNAKE_MAX - (snakeLen - 1)) % SNAKE_MAX);
    cell(snakeX[tail], snakeY[tail], false);
  } else if (snakeLen < SNAKE_MAX) {
    snakeLen++;
  }

  snakeHead = (uint8_t)((snakeHead + 1) % SNAKE_MAX);
  snakeX[snakeHead] = (uint8_t)nx;
  snakeY[snakeHead] = (uint8_t)ny;
  cell((uint8_t)nx, (uint8_t)ny, true);

  if (ate) {
    snakeScore++;
    if (snakeStep > 60) {
      snakeStep = (uint16_t)(snakeStep - 4);
    }
    statusLine("SNAKE", snakeScore);
    dropFood();
  }
  oled.display();
}

// --------------------------------------------------------------- the breakout

const int16_t BRICK_W = 16;
const int16_t BRICK_H = 5;
const uint8_t BRICK_COLS = 8;
const uint8_t BRICK_ROWS = 3;
const int16_t BRICK_TOP = FIELD_TOP + 2;

const int16_t PADDLE_W = 22;
const int16_t PADDLE_Y = 61;
const int16_t SUB = 16;  // sixteenths of a pixel, the ball's own units

uint8_t bricks[BRICK_ROWS];  // one bit per column, 1 = still there
int16_t paddleX = 53;
int16_t drawnPaddleX = -1;
int16_t ballX = 64 * SUB;
int16_t ballY = 40 * SUB;
int16_t ballDX = SUB;
int16_t ballDY = -SUB;
int16_t drawnBallX = -1;
int16_t drawnBallY = -1;
uint16_t brickScore = 0;
uint8_t lives = 3;
bool serving = true;
uint32_t bricksLast = 0;

static void drawBrick(uint8_t rowIndex, uint8_t col, bool on)
{
  oled.fillRect((int16_t)(col * BRICK_W + 1), (int16_t)(BRICK_TOP + rowIndex * (BRICK_H + 2)),
                BRICK_W - 2, BRICK_H, on);
}

static void startBricks()
{
  oled.clear();
  for (uint8_t r = 0; r < BRICK_ROWS; r++) {
    bricks[r] = 0xff;
    for (uint8_t c = 0; c < BRICK_COLS; c++) {
      drawBrick(r, c, true);
    }
  }
  paddleX = 53;
  drawnPaddleX = -1;
  drawnBallX = -1;
  drawnBallY = -1;
  brickScore = 0;
  lives = 3;
  serving = true;
  ballX = (int16_t)((paddleX + PADDLE_W / 2) * SUB);
  ballY = (PADDLE_Y - 3) * SUB;
  ballDX = SUB;
  ballDY = -SUB;
  bricksLast = millis();
  statusLine("BALLS", lives);
  oled.display();
  screen = BRICKS;
}

static bool anyBricks()
{
  for (uint8_t r = 0; r < BRICK_ROWS; r++) {
    if (bricks[r] != 0) {
      return true;
    }
  }
  return false;
}

/** Knock out the brick under this point, if there is one. */
static bool hitBrick(int16_t x, int16_t y)
{
  if (y < BRICK_TOP) {
    return false;
  }
  int16_t band = (int16_t)(y - BRICK_TOP);
  uint8_t r = (uint8_t)(band / (BRICK_H + 2));
  if (r >= BRICK_ROWS || band % (BRICK_H + 2) >= BRICK_H) {
    return false;
  }
  if (x < 0 || x >= 128) {
    return false;
  }
  uint8_t c = (uint8_t)(x / BRICK_W);
  if ((bricks[r] & (uint8_t)(1u << c)) == 0u) {
    return false;
  }
  bricks[r] = (uint8_t)(bricks[r] & ~(1u << c));
  drawBrick(r, c, false);
  brickScore++;
  return true;
}

static void bricksOver(const char *why)
{
  overTitle = why;
  overScore = brickScore;
  screen = OVER;
}

static void stepBricks()
{
  if ((uint32_t)(millis() - bricksLast) < 16u) {
    return;
  }
  bricksLast = millis();

  paddleX = (int16_t)(paddleX + pad.dx() * 3);
  if (paddleX < 0) {
    paddleX = 0;
  }
  if (paddleX > 128 - PADDLE_W) {
    paddleX = (int16_t)(128 - PADDLE_W);
  }

  if (serving) {
    ballX = (int16_t)((paddleX + PADDLE_W / 2) * SUB);
    ballY = (PADDLE_Y - 3) * SUB;
    if (pad.pressed(PAD_A)) {
      serving = false;
    }
  } else {
    ballX = (int16_t)(ballX + ballDX);
    ballY = (int16_t)(ballY + ballDY);
    int16_t px = (int16_t)(ballX / SUB);
    int16_t py = (int16_t)(ballY / SUB);

    if (px <= 0) {
      ballX = 0;
      ballDX = (int16_t)-ballDX;
    }
    if (px >= 126) {
      ballX = 126 * SUB;
      ballDX = (int16_t)-ballDX;
    }
    if (py <= FIELD_TOP) {
      ballY = (int16_t)(FIELD_TOP * SUB);
      ballDY = (int16_t)-ballDY;
    }

    if (hitBrick(px, py) || hitBrick((int16_t)(px + 1), py)) {
      ballDY = (int16_t)-ballDY;
      if (!anyBricks()) {
        bricksOver("CLEARED");
        return;
      }
    }

    // The paddle: where the ball strikes sets the angle it leaves at, which is
    // the one rule that makes this a game of placement.
    if (ballDY > 0 && py + 1 >= PADDLE_Y && py <= PADDLE_Y + 2 && px + 1 >= paddleX &&
        px <= paddleX + PADDLE_W) {
      ballDY = (int16_t)-ballDY;
      ballY = (int16_t)((PADDLE_Y - 2) * SUB);
      int16_t offset = (int16_t)(px - (paddleX + PADDLE_W / 2));
      ballDX = (int16_t)(offset * SUB / 8);
      if (ballDX == 0) {
        ballDX = SUB / 4;
      }
    }

    if (py > FIELD_BOTTOM) {
      lives--;
      statusLine("BALLS", lives);
      if (lives == 0) {
        bricksOver("BREAKOUT");
        return;
      }
      serving = true;
    }
  }

  // Rub out what moved, draw it where it is now.
  if (drawnPaddleX != paddleX) {
    if (drawnPaddleX >= 0) {
      oled.fillRect(drawnPaddleX, PADDLE_Y, PADDLE_W, 2, false);
    }
    oled.fillRect(paddleX, PADDLE_Y, PADDLE_W, 2, true);
    drawnPaddleX = paddleX;
  }
  int16_t bx = (int16_t)(ballX / SUB);
  int16_t by = (int16_t)(ballY / SUB);
  if (bx != drawnBallX || by != drawnBallY) {
    if (drawnBallX >= 0) {
      oled.fillRect(drawnBallX, drawnBallY, 2, 2, false);
    }
    oled.fillRect(bx, by, 2, 2, true);
    drawnBallX = bx;
    drawnBallY = by;
  }
  oled.display();
}

// ------------------------------------------------------------- game over, man

static void drawOver()
{
  oled.clear();
  oled.text(8, 8, overTitle, 2);
  int16_t x = oled.text(8, 30, "SCORE ");
  oled.number(x, 30, (long)overScore);
  oled.text(0, 50, "A again  B menu");
  oled.display();
}

// --------------------------------------------------------------------- Arduino

void setup()
{
  pad.begin();
  if (!oled.begin()) {
    // Nothing acknowledged the panel's address: say so rather than drawing
    // into a bus with nothing on it.
    return;
  }
  drawMenu();
}

void loop()
{
  pad.update();
  // Everything below asks `pressed()`, which stays true until the next change:
  // without taking the picture as read at the end of the pass, one press of
  // Down would flip the menu on every turn of the loop until the thumb came
  // off it. `consume()` is what makes a press a press.

  // The first press of the session seeds the generator with a reaction time.
  if (!seeded && pad.mask() != 0) {
    randomSeed(micros());
    seeded = true;
  }

  switch (screen) {
    case MENU:
      if (pad.pressed(PAD_UP) || pad.pressed(PAD_DOWN)) {
        choice = (uint8_t)(choice ^ 1u);
        drawMenu();
      }
      if (pad.pressed(PAD_A) || pad.pressed(PAD_START)) {
        if (choice == 0) {
          startSnake();
        } else {
          startBricks();
        }
      }
      break;

    case SNAKE:
      if (pad.pressed(PAD_SELECT)) {
        screen = MENU;
        drawMenu();
        break;
      }
      stepSnake();
      if (screen == OVER) {
        drawOver();
      }
      break;

    case BRICKS:
      if (pad.pressed(PAD_SELECT)) {
        screen = MENU;
        drawMenu();
        break;
      }
      stepBricks();
      if (screen == OVER) {
        drawOver();
      }
      break;

    case OVER:
      if (pad.pressed(PAD_A) || pad.pressed(PAD_START)) {
        if (choice == 0) {
          startSnake();
        } else {
          startBricks();
        }
      } else if (pad.pressed(PAD_B) || pad.pressed(PAD_SELECT)) {
        screen = MENU;
        drawMenu();
      }
      break;
  }

  pad.consume();
}

Code walkthrough

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

stepSnake sketch.ino

Reads the D-pad and picks a direction, refusing to turn straight back on itself. Then it waits until snakeStep milliseconds have passed since the last move. On each move it checks the walls and the snake's own body. If it did not eat, it erases the tail. It lights the new head. Eating food adds a point and speeds the snake up.

static void stepSnake()
{
  int8_t dx = pad.dx();
  int8_t dy = pad.dy();
  // A snake cannot turn back on itself, and a diagonal is not a direction: the
  // D-pad's up and down win over its left and right when both arrive.
  if (dy != 0 && snakeDY == 0) {
    snakeDX = 0;
    snakeDY = dy;
  } else if (dx != 0 && snakeDX == 0) {
    snakeDX = dx;
    snakeDY = 0;
  }

  if ((uint32_t)(millis() - snakeLast) < (uint32_t)snakeStep) {
    return;
  }
  snakeLast = millis();

  int16_t nx = (int16_t)snakeX[snakeHead] + snakeDX;
  int16_t ny = (int16_t)snakeY[snakeHead] + snakeDY;
  if (nx < 0 || ny < 0 || nx >= (int16_t)GRID_W || ny >= (int16_t)GRID_H) {
    snakeOver();
    return;
  }
  if (onSnake((uint8_t)nx, (uint8_t)ny)) {
    snakeOver();
    return;
  // ... 26 more lines in the editor

stepBricks sketch.ino

Runs one Breakout frame every 16 milliseconds. Left and right slide the paddle 3 pixels. While serving, the ball rides the paddle until you press A. Then it moves, bounces off the walls and ceiling, and knocks out bricks. Where it hits the paddle sets the angle it leaves at. Missing it costs a ball out of three.

static void stepBricks()
{
  if ((uint32_t)(millis() - bricksLast) < 16u) {
    return;
  }
  bricksLast = millis();

  paddleX = (int16_t)(paddleX + pad.dx() * 3);
  if (paddleX < 0) {
    paddleX = 0;
  }
  if (paddleX > 128 - PADDLE_W) {
    paddleX = (int16_t)(128 - PADDLE_W);
  }

  if (serving) {
    ballX = (int16_t)((paddleX + PADDLE_W / 2) * SUB);
    ballY = (PADDLE_Y - 3) * SUB;
    if (pad.pressed(PAD_A)) {
      serving = false;
    }
  } else {
    ballX = (int16_t)(ballX + ballDX);
    ballY = (int16_t)(ballY + ballDY);
    int16_t px = (int16_t)(ballX / SUB);
    int16_t py = (int16_t)(ballY / SUB);

    if (px <= 0) {
  // ... 64 more lines in the editor

loop sketch.ino

Updates the buttons first, then seeds the random generator on the first press. A switch on screen decides what to run: the menu, Snake, Breakout or the game over screen. At the end, pad.consume() marks the presses as read, so one press counts once.

void loop()
{
  pad.update();
  // Everything below asks `pressed()`, which stays true until the next change:
  // without taking the picture as read at the end of the pass, one press of
  // Down would flip the menu on every turn of the loop until the thumb came
  // off it. `consume()` is what makes a press a press.

  // The first press of the session seeds the generator with a reaction time.
  if (!seeded && pad.mask() != 0) {
    randomSeed(micros());
    seeded = true;
  }

  switch (screen) {
    case MENU:
      if (pad.pressed(PAD_UP) || pad.pressed(PAD_DOWN)) {
        choice = (uint8_t)(choice ^ 1u);
        drawMenu();
      }
      if (pad.pressed(PAD_A) || pad.pressed(PAD_START)) {
        if (choice == 0) {
          startSnake();
        } else {
          startBricks();
        }
      }
      break;
  // ... 41 more lines in the editor

Troubleshooting

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

  • The screen stays blank and nothing responds This project
    Check SDA on pin 5 and SCL on pin 6, and that the cabinet has 3V3 and GND. In setup, the sketch quits early if nothing answers at the OLED's address.
  • A button does nothing or acts like a different one This project
    Compare each lead to the Cabinet pad(4, 10, 1, 0, 8, 7, 3, 2) order: up, down, left, right, A, B, Start, Select.
  • Snake or Breakout never starts from the menu This project
    Press A or Start, not Select. Select only returns you to the menu.
  • The screen stays blank. Handheld console
    The OLED uses I2C, usually at address 0x3C. Check SDA, SCL, power, and that your code uses the right address and display size. Call display() after drawing.
  • Buttons read wrong or do not respond. Handheld console
    Buttons go to ground, so use INPUT_PULLUP and treat LOW as pressed. Debounce them in code.
  • 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.