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.
AdvancedRuns in your browser. Free, and no account needed.
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// 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();
}
Parts list
3 parts, plus the jumper wires. Every one is in the editor's parts bin.
How it is wired
12 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; Handheld console pin VCC
- ESP32-C3-DevKitM-1 pin 2; Handheld console pin SELECT
- ESP32-C3-DevKitM-1 pin 3; Handheld console pin START
- Ground: ESP32-C3-DevKitM-1 pin GND; Handheld console pin GND
- ESP32-C3-DevKitM-1 pin 0; Handheld console pin RIGHT
- ESP32-C3-DevKitM-1 pin 1; Handheld console pin LEFT
- ESP32-C3-DevKitM-1 pin 10; Handheld console pin DOWN
- ESP32-C3-DevKitM-1 pin 4; Handheld console pin UP
- ESP32-C3-DevKitM-1 pin 5; Handheld console pin SDA
- ESP32-C3-DevKitM-1 pin 6; Handheld console pin SCL
- ESP32-C3-DevKitM-1 pin 7; Handheld console pin B
- ESP32-C3-DevKitM-1 pin 8; Handheld console pin A
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.