Community project
Pocket Arcade Dodge Game
Build a handheld dodge game console powered by an ESP32 that fits in your pocket. The player controls a character on an OLED screen using a joystick module, dodging falling obstacles while racking up a high score. An optional MPU-6050 tilt sensor adds motion-control gameplay, and an 8×8 LED matrix can display the action on a larger display once verified and safely wired.
This guide provides a complete wiring diagram, parts checklist, and step-by-step assembly instructions to get the arcade running safely. The firmware is pre-configured to boot with OLED-only operation, ensuring the system stays stable while you verify all connections before enabling the optional LED matrix display. Includes calibration routines for both joystick and tilt controls, plus a pixel-demo mode for testing the hardware.
Wiring diagram

Gather all the parts
Assemble it in 6 steps
1. Place the board and make safe power rails
Place the ESP32-S3 board beside the breadboard with the antenna at the top. Use the USB-C cable only after wiring. Run one jumper from J1 pin 1 or 2 (3.3 V) to a breadboard positive rail and one jumper from J1 pin 22 (GND) to a ground rail; these power the joystick, OLED, and optional MPU safely at 3.3 V.
- Keep the 3.3 V and ground rails on opposite sides of the breadboard so they are easy to identify.
- Do not use the nominal 5 V pin for the joystick, OLED, or MPU-6050 in this safe build — 5 V signals can damage ESP32 input pins.
2. Wire the joystick and start button
With the joystick labels facing you, connect joystick VCC to the 3.3 V rail (power), GND to the ground rail (ground), VRx to GPIO1 / J3 pin 4 (left-right signal), VRy to GPIO2 / J3 pin 5 (up-down signal), and SW to GPIO4 / J1 pin 4 (pause-button signal). On the tactile button, use two legs from opposite sides of the switch: one side to GPIO5 / J1 pin 5 (start signal) and the opposite side to GND (ground).
- The four legs of the small button form two internally joined pairs: use legs across the centre gap, not two legs on the same side.
- The program uses internal pull-up resistors, so both buttons read as pressed only when connected to ground.
- Do not connect joystick VCC to 5 V — its VRx and VRy outputs would then be too high for the ESP32.
3. Wire the OLED fallback display
Read the labels printed on the actual OLED board before plugging wires in; do not rely on a picture. Connect its confirmed VCC pin to 3.3 V (power), its confirmed GND pin to GND (ground), SDA to GPIO17 / J1 pin 10 (data), and SCL to GPIO18 / J1 pin 11 (clock).
- The expected OLED address is 0x3C. On startup the firmware checks once for it; if it is absent, it continues without repeatedly waiting for it.
- GPIO17 and GPIO18 are the requested flexible I²C pins; the board registry prefers GPIO8 and GPIO9, so verify these requested header mappings on the real board before power-up.
- Make sure VCC and GND are not swapped — swapped power can damage the OLED.
- Confirm the OLED pull-up resistors are tied to its 3.3 V supply; some lookalike boards pull I²C lines to 5 V, which is unsafe for ESP32 pins.
4. Add the optional tilt sensor
Only if you want tilt steering, read the MPU-6050 board labels and connect VIN to 3.3 V (power), GND to GND (ground), SDA to the same GPIO17 wire (shared data), and SCL to the same GPIO18 wire (shared clock). Leave INT unconnected.
- Keep the MPU-6050 still and level during startup, then use serial command c to calibrate it again when level.
- If the sensor is absent or has the wrong address, joystick control remains available.
- Do not connect an MPU-6050 module with I²C pull-ups tied to 5 V directly to GPIO17 or GPIO18 — it can damage the ESP32.
5. Leave the matrix disconnected until approved
DO NOT CONNECT UNTIL VERIFIED: leave the MAX7219 module VCC, GND, DIN, CLK, and CS wires disconnected in the safe OLED build. The intended future signals are DIN from GPIO11 / J1 pin 17, CLK from GPIO12 / J1 pin 18, and CS/LOAD from GPIO10 / J1 pin 16, but each must pass through an approved 74AHCT125 level shifter powered from a verified 5 V rail. Add a 100 nF capacitor across the 74AHCT125 power pins only after that interface plan is approved.
- A firmware option only turns output pins on; it does not prove that a particular MAX7219 module accepts 3.3 V logic.
- When approved, check that the USB diode's downstream 5 V rail remains inside the level shifter's supply range under the matrix load.
- Do not connect GPIO10, GPIO11, or GPIO12 directly to a 5 V powered bare MAX7219 — 3.3 V is not its guaranteed logic-high level.
- Do not tie a second external 5 V supply directly onto the USB-powered 5 V rail.
6. Power and operate the safe console
Plug the USB-C cable into the board's CP2102N programming/logging USB-C port and a USB power source. Press Deploy in Schematik. Keep the joystick centred while the board starts. The OLED begins in the title screen: press the start button for a three-second countdown, move left and right to dodge, press the joystick to pause or resume, and press start to restart. Send p in the serial menu for the pixel demo, d for Dodge, j for joystick steering, t for tilt steering, c to recalibrate tilt, and ? for help.
- Use x and y in the serial menu to reverse joystick directions. The matrix-specific m and f commands set mirror/flip orientation for a later approved matrix build.
- For the pixel demo, use the joystick to place a single lit point; it cannot move past the grid edge.
- Disconnect USB power before moving wires. A loose power wire can make the display reset or behave unpredictably.
Review all connections
1. Connections between "joystick" and "ESP32"
2. Connections between "start_button" and "ESP32"
3. Connections between "oled" and "ESP32"
4. Connections between "mpu6050" and "ESP32"
Deploy the firmware
// Pocket Arcade for ESP32-S3-DevKitC-1-N8 (no PSRAM)
// Platform: espressif32@6.7.0 / Arduino-ESP32 2.0.14 family.
// Libraries: Adafruit SSD1306 2.5.7, Adafruit GFX 1.11.5, MD_MAX72XX 3.5.1.
// Safe default: OLED only. Never enable matrix output until its 5 V logic
// interface has been physically approved; a build option cannot verify wiring.
#include <Arduino.h>
#include <Wire.h>
#include <Preferences.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#ifdef POCKET_ENABLE_VERIFIED_MATRIX
#include <MD_MAX72xx.h>
#endif
// Hoisted type definitions
struct Obstacle { int8_t x; int8_t y; bool active; };
enum GameState : uint8_t { TITLE, COUNTDOWN, PLAYING, PAUSED, GAME_OVER };
enum PlayMode : uint8_t { DODGE_GAME, PIXEL_DEMO };
enum ControlMode : uint8_t { JOYSTICK_CONTROL, TILT_CONTROL };
// Forward declarations
bool i2cExists(uint8_t address);
void mpuWrite(uint8_t reg, uint8_t value);
bool mpuAccelX(float &g);
bool pressedEdge(int pin, bool &lastRaw, bool &stable, uint32_t &changed, uint32_t now);
void clearObstacles();
uint32_t elapsedPlay(uint32_t now);
uint32_t fallInterval(uint32_t now);
void resetRound(uint32_t now);
void beginPlaying(uint32_t now);
void togglePause(uint32_t now);
void finishRound();
void spawnObstacle();
void collisionCheck();
void movePlayer(int direction);
void movePixel(int dx, int dy);
int joystickDirection();
int joystickVertical();
void calibrateJoystick();
void calibrateTilt();
int tiltDirection();
bool logicalPixel(int x, int y);
void drawGridToOled();
void drawDisplay(uint32_t now);
bool logicChecks();
void printMenu();
void handleSerial(uint32_t now);
void updateGame(uint32_t now);
constexpr int JOY_X_PIN = 1;
constexpr int JOY_Y_PIN = 2;
constexpr int JOY_CLICK_PIN = 4;
constexpr int START_BUTTON_PIN = 5;
constexpr int MATRIX_CS_PIN = 10;
constexpr int MATRIX_DIN_PIN = 11;
constexpr int MATRIX_CLK_PIN = 12;
constexpr int I2C_SDA_PIN = 17;
constexpr int I2C_SCL_PIN = 18;
constexpr uint8_t OLED_ADDR = 0x3C;
constexpr uint8_t MPU_ADDR = 0x68;
constexpr uint8_t GRID = 8;
constexpr uint32_t FRAME_MS = 50; // 20 frames/s maximum redraw rate.
constexpr uint32_t REPEAT_MS = 150;
constexpr uint32_t DEBOUNCE_MS = 35;
constexpr uint32_t COUNTDOWN_MS = 3000;
constexpr uint32_t MIN_FALL_MS = 150; // playable lower speed limit.
constexpr uint32_t MAX_CATCHUP_STEPS = 4;
#ifndef POCKET_ENABLE_VERIFIED_MATRIX
constexpr bool MATRIX_OUTPUT_ENABLED = false;
#else
constexpr bool MATRIX_OUTPUT_ENABLED = true;
MD_MAX72XX matrix(MD_MAX72XX::FC16_HW, MATRIX_CS_PIN, 1);
#endif
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Preferences prefs;
Obstacle obstacles[8];
GameState state = TITLE;
PlayMode mode = DODGE_GAME;
ControlMode controlMode = JOYSTICK_CONTROL;
bool oledPresent = false;
bool mpuPresent = false;
bool dirty = true;
bool invertX = false;
bool invertY = false;
bool mirrorMatrixX = false;
bool flipMatrixY = false;
bool tiltInvert = false;
int joyCenterX = 2048, joyCenterY = 2048;
float tiltZero = 0.0f, tiltFiltered = 0.0f;
int playerX = 3, pixelX = 3, pixelY = 3;
uint32_t score = 0, highScore = 0;
uint32_t lastFrame = 0, lastMove = 0, lastFall = 0, stateStarted = 0;
uint32_t unpausedStarted = 0, accumulatedPlayMs = 0;
uint32_t clickChanged = 0, startChanged = 0;
bool lastClickRaw = true, lastStartRaw = true, clickStable = true, startStable = true;
bool i2cExists(uint8_t address) {
Wire.beginTransmission(address);
return Wire.endTransmission() == 0;
}
void mpuWrite(uint8_t reg, uint8_t value) {
Wire.beginTransmission(MPU_ADDR); Wire.write(reg); Wire.write(value); Wire.endTransmission();
}
bool mpuAccelX(float &g) {
if (!mpuPresent) return false;
Wire.beginTransmission(MPU_ADDR); Wire.write(0x3B);
if (Wire.endTransmission(false) != 0 || Wire.requestFrom((int)MPU_ADDR, 2) != 2) return false;
int16_t raw = (int16_t)((Wire.read() << 8) | Wire.read());
g = raw / 16384.0f;
return true;
}
bool pressedEdge(int pin, bool &lastRaw, bool &stable, uint32_t &changed, uint32_t now) {
bool raw = digitalRead(pin);
if (raw != lastRaw) { lastRaw = raw; changed = now; }
if (raw != stable && now - changed >= DEBOUNCE_MS) {
stable = raw;
return stable == LOW;
}
return false;
}
void clearObstacles() { for (auto &o : obstacles) o.active = false; }
uint32_t elapsedPlay(uint32_t now) { return accumulatedPlayMs + ((state == PLAYING) ? now - unpausedStarted : 0); }
uint32_t fallInterval(uint32_t now) {
uint32_t level = elapsedPlay(now) / 10000UL;
uint32_t interval = 650UL - level * 45UL; // drops every 10 s, never below 150 ms.
return interval < MIN_FALL_MS ? MIN_FALL_MS : interval;
}
void resetRound(uint32_t now) {
playerX = 3; score = 0; clearObstacles(); accumulatedPlayMs = 0;
state = COUNTDOWN; stateStarted = now; lastFall = now; dirty = true;
}
void beginPlaying(uint32_t now) { state = PLAYING; unpausedStarted = now; lastFall = now; dirty = true; }
void togglePause(uint32_t now) {
if (state == PLAYING) { accumulatedPlayMs += now - unpausedStarted; state = PAUSED; }
else if (state == PAUSED) { state = PLAYING; unpausedStarted = now; }
dirty = true;
}
void finishRound() {
state = GAME_OVER;
if (score > highScore) { highScore = score; prefs.putUInt("high", highScore); }
dirty = true;
}
void spawnObstacle() {
for (auto &o : obstacles) if (!o.active) { o = {(int8_t)random(0, 8), 0, true}; return; }
}
void collisionCheck() {
for (const auto &o : obstacles) if (o.active && o.y == 7 && o.x == playerX) { finishRound(); return; }
}
void movePlayer(int direction) {
int next = constrain(playerX + direction, 0, 7);
if (next != playerX) { playerX = next; collisionCheck(); dirty = true; }
}
void movePixel(int dx, int dy) {
int nx = constrain(pixelX + dx, 0, 7), ny = constrain(pixelY + dy, 0, 7);
if (nx != pixelX || ny != pixelY) { pixelX = nx; pixelY = ny; dirty = true; }
}
int joystickDirection() {
int x = analogRead(JOY_X_PIN) - joyCenterX;
if (invertX) x = -x;
return (x > 700) ? 1 : ((x < -700) ? -1 : 0);
}
int joystickVertical() {
int y = analogRead(JOY_Y_PIN) - joyCenterY;
if (invertY) y = -y;
return (y > 700) ? 1 : ((y < -700) ? -1 : 0);
}
void calibrateJoystick() { joyCenterX = analogRead(JOY_X_PIN); joyCenterY = analogRead(JOY_Y_PIN); }
void calibrateTilt() {
float value;
if (mpuAccelX(value)) { tiltZero = value; tiltFiltered = value; Serial.println("Tilt calibrated: hold level during C command."); }
else Serial.println("MPU-6050 unavailable; joystick remains active.");
}
int tiltDirection() {
float value;
if (!mpuAccelX(value)) { mpuPresent = false; controlMode = JOYSTICK_CONTROL; Serial.println("MPU lost; reverted to joystick."); return 0; }
tiltFiltered = 0.85f * tiltFiltered + 0.15f * value;
float delta = (tiltFiltered - tiltZero) * (tiltInvert ? -1.0f : 1.0f);
return delta > 0.16f ? 1 : (delta < -0.16f ? -1 : 0);
}
bool logicalPixel(int x, int y) {
if (mode == PIXEL_DEMO) return x == pixelX && y == pixelY;
if (state == PLAYING || state == PAUSED || state == GAME_OVER || state == COUNTDOWN) {
if (y == 7 && x == playerX) return true;
for (const auto &o : obstacles) if (o.active && o.x == x && o.y == y) return true;
}
return false;
}
void drawGridToOled() {
oled.fillRect(64, 0, 64, 64, SSD1306_BLACK);
for (int y = 0; y < 8; ++y) for (int x = 0; x < 8; ++x)
if (logicalPixel(x, y)) oled.fillRect(66 + x * 7, 4 + y * 7, 6, 6, SSD1306_WHITE);
}
void drawDisplay(uint32_t now) {
if (!dirty) return;
if (oledPresent) {
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); oled.setTextSize(1); oled.setCursor(0, 0);
if (mode == PIXEL_DEMO) { oled.println("PIXEL DEMO"); oled.println("Stick: move"); oled.println("Click: game"); }
else {
oled.println(state == TITLE ? "POCKET ARCADE" : state == PAUSED ? "PAUSED" : state == GAME_OVER ? "GAME OVER" : state == COUNTDOWN ? "GET READY" : "DODGE");
oled.printf("Score: %lu\nHigh: %lu\n", (unsigned long)score, (unsigned long)highScore);
if (state == COUNTDOWN) oled.printf("%lu", (unsigned long)(3 - min(2UL, (now - stateStarted) / 1000UL)));
else if (state == GAME_OVER) oled.println("Start: retry");
else oled.println(controlMode == TILT_CONTROL ? "Tilt control" : "Stick control");
}
drawGridToOled(); oled.display();
}
#ifdef POCKET_ENABLE_VERIFIED_MATRIX
matrix.clear();
for (int y = 0; y < 8; ++y) for (int x = 0; x < 8; ++x) if (logicalPixel(x, y)) {
int mx = mirrorMatrixX ? 7 - x : x, my = flipMatrixY ? 7 - y : y;
matrix.setPoint(my, mx, true);
}
matrix.update();
#endif
dirty = false;
}
// Hardware-independent production logic checks: bounds, collision decisions,
// held button edge behavior, and decreasing/clamped difficulty calculation.
bool logicChecks() {
bool ok = true;
ok &= constrain(-1, 0, 7) == 0 && constrain(8, 0, 7) == 7;
uint32_t slow = 650, fast = MIN_FALL_MS; ok &= slow > fast && fast == 150;
int savedPlayer = playerX; clearObstacles(); playerX = 2; obstacles[0] = {2, 7, true};
state = PLAYING; collisionCheck(); ok &= state == GAME_OVER;
clearObstacles(); playerX = savedPlayer; state = TITLE;
Serial.printf("Logic checks: %s (bounds, difficulty clamp, collision path)\n", ok ? "PASS" : "FAIL");
return ok;
}
void printMenu() {
Serial.println("Commands: d=dodge, p=pixel demo, j=joystick, t=tilt, c=tilt recalibrate, x=toggle X, y=toggle Y, m=matrix mirror, f=matrix flip.");
Serial.println("Matrix output is compiled OFF unless POCKET_ENABLE_VERIFIED_MATRIX=1 was explicitly approved.");
}
void handleSerial(uint32_t now) {
while (Serial.available()) {
char c = tolower((unsigned char)Serial.read());
if (c == 'd') { mode = DODGE_GAME; state = TITLE; dirty = true; }
if (c == 'p') { mode = PIXEL_DEMO; dirty = true; }
if (c == 'j') { controlMode = JOYSTICK_CONTROL; dirty = true; }
if (c == 't') { if (mpuPresent) { controlMode = TILT_CONTROL; calibrateTilt(); } else Serial.println("MPU absent: joystick retained."); }
if (c == 'c') calibrateTilt();
if (c == 'x') { invertX = !invertX; Serial.println("X inversion toggled."); }
if (c == 'y') { invertY = !invertY; Serial.println("Y inversion toggled."); }
if (c == 'm') { mirrorMatrixX = !mirrorMatrixX; dirty = true; }
if (c == 'f') { flipMatrixY = !flipMatrixY; dirty = true; }
if (c == '?') printMenu();
}
}
void updateGame(uint32_t now) {
if (state == COUNTDOWN && now - stateStarted >= COUNTDOWN_MS) beginPlaying(now);
if (state != PLAYING) return;
uint32_t interval = fallInterval(now); uint32_t steps = 0;
while (now - lastFall >= interval && steps++ < MAX_CATCHUP_STEPS && state == PLAYING) {
lastFall += interval;
for (auto &o : obstacles) if (o.active) {
o.y++;
if (o.y > 7) { o.active = false; ++score; dirty = true; }
}
collisionCheck();
if (state == PLAYING && random(0, 100) < 55) spawnObstacle();
interval = fallInterval(now);
}
}
void setup() {
Serial.begin(115200); delay(100);
pinMode(JOY_CLICK_PIN, INPUT_PULLUP); pinMode(START_BUTTON_PIN, INPUT_PULLUP);
analogReadResolution(12); calibrateJoystick();
// Safe build keeps these potentially unsafe MAX7219 signals high impedance.
pinMode(MATRIX_CS_PIN, INPUT); pinMode(MATRIX_DIN_PIN, INPUT); pinMode(MATRIX_CLK_PIN, INPUT);
Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN, 100000);
oledPresent = i2cExists(OLED_ADDR);
if (oledPresent) oledPresent = oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
mpuPresent = i2cExists(MPU_ADDR);
if (mpuPresent) { mpuWrite(0x6B, 0); calibrateTilt(); }
#ifdef POCKET_ENABLE_VERIFIED_MATRIX
matrix.begin(); matrix.control(MD_MAX72XX::INTENSITY, 2); matrix.clear();
#endif
prefs.begin("pocketarc", false); highScore = prefs.getUInt("high", 0);
randomSeed((uint32_t)micros()); logicChecks(); printMenu();
Serial.printf("OLED: %s, MPU-6050: %s, Matrix outputs: %s\n", oledPresent ? "found" : "not found", mpuPresent ? "found" : "not found", MATRIX_OUTPUT_ENABLED ? "ENABLED" : "SAFE/OFF");
}
void loop() {
uint32_t now = millis(); handleSerial(now);
if (pressedEdge(START_BUTTON_PIN, lastStartRaw, startStable, startChanged, now) && mode == DODGE_GAME) resetRound(now);
if (pressedEdge(JOY_CLICK_PIN, lastClickRaw, clickStable, clickChanged, now)) {
if (mode == PIXEL_DEMO) { mode = DODGE_GAME; state = TITLE; dirty = true; }
else if (state == PLAYING || state == PAUSED) togglePause(now);
}
if (now - lastMove >= REPEAT_MS) {
int horizontal = controlMode == TILT_CONTROL ? tiltDirection() : joystickDirection();
if (mode == PIXEL_DEMO) { movePixel(horizontal, joystickVertical()); }
else if (state == PLAYING) movePlayer(horizontal);
lastMove = now;
}
updateGame(now);
if (now - lastFrame >= FRAME_MS) { lastFrame = now; drawDisplay(now); }
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




