Built-in project · Arduino Mega 2560 R3

Pinball table on an Arduino Mega

Flippers, pop bumpers, targets and a drain on a Mega, with a kicker solenoid, a four-digit score, WS2812 lamps and the high score kept in EEPROM. The whole build, an Arduino Mega and 19 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 Runs in your browser. Free, and no account needed.

Build it as the Pinball Table Kit: the parts in numbered bags and a booklet that checks every step. Free.

Pinball table · Arduino Mega 2560 R3live0.000 s 0.00x
Press START, then LAUNCH, and hold the flipper buttons.
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 · Arduino Mega 2560 R3
// Pinball table: the whole controller under the playfield of a home-built table.
//
// Press START, then LAUNCH to send the ball up the shooter lane. Hold the two
// flipper buttons to raise the flippers. Click the targets and the pop bumpers
// to score, and push the card into the drain sensor's slot when the ball gets
// past you. Three balls a game, and the best score is kept in the EEPROM.
//
//   pins 35, 33, 31  standup targets A, B, C: microswitch NO to the pin, COM to GND
//   pins 27, 25      pop bumpers 1 and 2, wired the same way
//   pin 23           drain: photointerrupter S, HIGH while the ball blocks the beam
//   pin 29           gate of the IRLZ44N that fires the 12 V kicker solenoid
//   pins 51, 41      left and right flipper buttons, to GND (INPUT_PULLUP)
//   pin 47           START button, to GND
//   pin 45           LAUNCH button (the plunger), to GND
//   pins 49, 43      left and right flipper servos
//   pins 3, 2        TM1637 CLK and DIO: the four-digit score
//   pin 6            WS2812 strip DIN, sixteen playfield lamps
//   pin 12           passive piezo, its other leg on GND
//
// Every switch reads LOW when it closes, with the chip's own pull-up holding
// the pin HIGH the rest of the time, so not one of them needs a resistor.
//
// The table is a state machine. Every state decides what each switch means:
//
//   ATTRACT    lights chase, the display shows the high score; START begins
//   LAUNCH     a ball waits in the shooter lane; LAUNCH puts it in play
//   PLAY       targets, bumpers and flippers score; the drain ends the ball
//   DRAIN      a short pause with a sad tune, then the next ball or game over
//   GAME OVER  the final score blinks
//   ENTRY      a new high score: flippers pick a letter, START keeps it
//
// Nothing here waits. Every job checks the clock and gets out of the way, so
// a flipper answers inside a few milliseconds whatever the lights and the
// speaker are doing.

#include <Adafruit_NeoPixel.h>
#include <EEPROM.h>
#include <Servo.h>
#include <TM1637Display.h>

const int DRAIN = 23;
const int KICKER = 29;
const int TM_CLK = 3;
const int TM_DIO = 2;
const int LAMP_PIN = 6;
const int SPEAKER = 12;
const int SERVO_L = 49;
const int SERVO_R = 43;

// The switches, and the pin each one pulls to GND.
enum { SW_FLIP_L, SW_FLIP_R, SW_START, SW_LAUNCH, SW_TA, SW_TB, SW_TC, SW_B1, SW_B2, SWITCHES };
const uint8_t SWITCH_PIN[SWITCHES] = {51, 41, 47, 45, 35, 33, 31, 27, 25};

// The lamps along the strip.
const int LAMPS = 16;
const int L_TARGET = 0;   // 0..2, one per target
const int L_BUMPER = 3;   // 3..4
const int L_MULT = 5;     // 5..8: 2x, 3x, 4x, 5x
const int L_SAVE = 9;     // shoot again
const int L_BALLS = 10;   // 10..12, balls left
const int L_ARROW = 13;   // 13..15, the shooter lane arrows

const int BALLS_PER_GAME = 3;
const int TARGET_POINTS = 50;
const int BUMPER_POINTS = 10;
const int BANK_BONUS = 200;
const int EXTRA_BALL_AT = 2500;
const int MAX_SCORE = 9999;
const unsigned long BALL_SAVE_MS = 6000;
const unsigned long KICK_MS = 30;
const unsigned long DEBOUNCE_MS = 5;

// Flipper angles: rest and raised, mirrored left to right.
const int L_REST = 150, L_UP = 100;
const int R_REST = 30, R_UP = 80;

// The EEPROM record: a two-byte tag, the score, three initials.
const int EE_TAG = 0;
const int EE_SCORE = 2;
const int EE_NAME = 4;

Adafruit_NeoPixel lamps(LAMPS, LAMP_PIN, NEO_GRB + NEO_KHZ800);
TM1637Display display(TM_CLK, TM_DIO);
Servo flipperL;
Servo flipperR;

enum State { ATTRACT, LAUNCHING, PLAY, DRAINING, GAME_OVER, ENTRY };
State state = ATTRACT;
unsigned long stateAt = 0;

int score = 0;
int lastScore = -1;
int ballsLeft = 0;
int ball = 0;
int multiplier = 1;
bool targetLit[3];
bool extraBallGiven = false;
unsigned long launchedAt = 0;
unsigned long kickUntil = 0;
unsigned long bumperFlash[2];

int highScore = 0;
char highName[4] = "ACE";
char entry[4] = "AAA";
int entryPos = 0;

// ---- Switches ----------------------------------------------------------

bool down[SWITCHES];
bool pressedEdge[SWITCHES];
unsigned long lockedUntil[SWITCHES];
bool drainBlocked = false;
bool drainEdge = false;

// Read every switch. A change is taken at once and the switch then ignored
// for a few milliseconds, which is all a microswitch needs to stop bouncing.
void readSwitches() {
  unsigned long now = millis();
  for (int i = 0; i < SWITCHES; i++) {
    pressedEdge[i] = false;
    if (now < lockedUntil[i]) continue;
    bool d = digitalRead(SWITCH_PIN[i]) == LOW;
    if (d != down[i]) {
      down[i] = d;
      lockedUntil[i] = now + DEBOUNCE_MS;
      if (d) pressedEdge[i] = true;
    }
  }
  bool blocked = digitalRead(DRAIN) == HIGH;
  drainEdge = blocked && !drainBlocked;
  drainBlocked = blocked;
}

// ---- Sound -------------------------------------------------------------

// A tune is a list of notes the speaker works through on its own time.
struct Note { uint16_t hz; uint16_t ms; };
const Note T_FLIP[] = {{180, 15}, {0, 0}};
const Note T_BUMP[] = {{1568, 25}, {2093, 25}, {0, 0}};
const Note T_TARGET[] = {{988, 40}, {1319, 60}, {0, 0}};
const Note T_BANK[] = {{523, 70}, {659, 70}, {784, 70}, {1047, 160}, {0, 0}};
const Note T_START[] = {{392, 90}, {523, 90}, {659, 90}, {784, 220}, {0, 0}};
const Note T_LAUNCH[] = {{200, 30}, {400, 30}, {800, 60}, {0, 0}};
const Note T_SAVE[] = {{1047, 80}, {1047, 80}, {1319, 160}, {0, 0}};
const Note T_DRAIN[] = {{392, 160}, {330, 160}, {262, 320}, {0, 0}};
const Note T_EXTRA[] = {{784, 60}, {988, 60}, {1175, 60}, {1568, 200}, {0, 0}};
const Note T_OVER[] = {{330, 200}, {294, 200}, {247, 200}, {196, 500}, {0, 0}};
const Note T_HIGH[] = {{523, 100}, {659, 100}, {784, 100}, {1047, 100}, {784, 100}, {1047, 400}, {0, 0}};
const Note T_CLICK[] = {{1200, 20}, {0, 0}};

const Note *tune = nullptr;
unsigned long noteEnds = 0;

void play(const Note *t) {
  tune = t;
  noteEnds = 0;
}

void serviceSound() {
  if (tune == nullptr || millis() < noteEnds) return;
  if (tune->ms == 0) {
    noTone(SPEAKER);
    tune = nullptr;
    return;
  }
  tone(SPEAKER, tune->hz);
  noteEnds = millis() + tune->ms;
  tune++;
}

// ---- The score display --------------------------------------------------

// Letters a seven-segment digit can actually show, and their segments.
const char LETTERS[] = "AbCdEFGHIJLnOPqrStUy";
const uint8_t LETTER_SEGS[] = {0x77, 0x7c, 0x39, 0x5e, 0x79, 0x71, 0x3d, 0x76, 0x30, 0x1e,
                               0x38, 0x54, 0x3f, 0x73, 0x67, 0x50, 0x6d, 0x78, 0x3e, 0x6e};

uint8_t segsFor(char c) {
  if (c >= '0' && c <= '9') return display.encodeDigit(c - '0');
  for (int i = 0; LETTERS[i]; i++) {
    if (LETTERS[i] == c || (LETTERS[i] ^ 0x20) == c) return LETTER_SEGS[i];
  }
  return 0;
}

void showText(const char *text) {
  uint8_t segs[4] = {0, 0, 0, 0};
  for (int i = 0; i < 4 && text[i]; i++) segs[i] = segsFor(text[i]);
  display.setSegments(segs);
}

int shownValue = -2;
void showScore(int value) {
  if (value == shownValue) return;
  shownValue = value;
  display.showNumberDec(value);
}

void showMessage(const char *text) {
  shownValue = -2;
  showText(text);
}

// ---- Playfield ----------------------------------------------------------

void kick() {
  digitalWrite(KICKER, HIGH);
  kickUntil = millis() + KICK_MS;
}

void addPoints(int points) {
  score += points * multiplier;
  if (score > MAX_SCORE) score = MAX_SCORE;
  if (!extraBallGiven && score >= EXTRA_BALL_AT) {
    extraBallGiven = true;
    ballsLeft++;
    play(T_EXTRA);
    Serial.println("extra ball!");
  }
}

void enter(State next) {
  state = next;
  stateAt = millis();
}

void newBall() {
  ball++;
  for (int i = 0; i < 3; i++) targetLit[i] = false;
  multiplier = 1;
  char text[5] = "bAL0";
  text[3] = (char)('0' + (ball > 9 ? 9 : ball));
  showMessage(text);
  Serial.print("ball ");
  Serial.print(ball);
  Serial.println(": press LAUNCH");
  enter(LAUNCHING);
}

void startGame() {
  score = 0;
  ball = 0;
  ballsLeft = BALLS_PER_GAME;
  extraBallGiven = false;
  play(T_START);
  Serial.println("game on: three balls");
  newBall();
}

void loseBall() {
  ballsLeft--;
  play(T_DRAIN);
  Serial.print("drain: score ");
  Serial.print(score);
  Serial.print(", balls left ");
  Serial.println(ballsLeft);
  enter(DRAINING);
}

void hitTarget(int t) {
  addPoints(TARGET_POINTS);
  Serial.print("target ");
  Serial.print((char)('A' + t));
  Serial.print("  score ");
  Serial.println(score);
  if (targetLit[t]) {
    play(T_CLICK);
    return;
  }
  targetLit[t] = true;
  if (targetLit[0] && targetLit[1] && targetLit[2]) {
    // The whole bank: a bonus, a bigger multiplier, and the bank resets.
    for (int i = 0; i < 3; i++) targetLit[i] = false;
    addPoints(BANK_BONUS);
    if (multiplier < 5) multiplier++;
    play(T_BANK);
    Serial.print("bank complete: multiplier ");
    Serial.print(multiplier);
    Serial.println("x");
  } else {
    play(T_TARGET);
  }
}

void hitBumper(int b) {
  kick();
  bumperFlash[b] = millis() + 120;
  addPoints(BUMPER_POINTS);
  play(T_BUMP);
  Serial.print("bumper ");
  Serial.print(b + 1);
  Serial.print("  score ");
  Serial.println(score);
}

void saveHighScore() {
  highScore = score;
  for (int i = 0; i < 3; i++) highName[i] = entry[i];
  EEPROM.write(EE_TAG, 'P');
  EEPROM.write(EE_TAG + 1, 'B');
  EEPROM.put(EE_SCORE, (uint16_t)highScore);
  for (int i = 0; i < 3; i++) EEPROM.write(EE_NAME + i, highName[i]);
  Serial.print("high score saved: ");
  Serial.print(highName);
  Serial.print(" ");
  Serial.println(highScore);
}

void loadHighScore() {
  if (EEPROM.read(EE_TAG) == 'P' && EEPROM.read(EE_TAG + 1) == 'B') {
    uint16_t saved = 0;
    EEPROM.get(EE_SCORE, saved);
    highScore = saved;
    for (int i = 0; i < 3; i++) highName[i] = (char)EEPROM.read(EE_NAME + i);
  } else {
    highScore = 500;  // a first score to beat on a blank chip
  }
}

// ---- Flippers -----------------------------------------------------------

bool flipLUp = false;
bool flipRUp = false;

void serviceFlippers() {
  // In the entry screen the flipper buttons pick letters instead.
  bool live = state == LAUNCHING || state == PLAY;
  bool wantL = live && down[SW_FLIP_L];
  bool wantR = live && down[SW_FLIP_R];
  if (wantL != flipLUp) {
    flipLUp = wantL;
    flipperL.write(wantL ? L_UP : L_REST);
    if (wantL) play(T_FLIP);
  }
  if (wantR != flipRUp) {
    flipRUp = wantR;
    flipperR.write(wantR ? R_UP : R_REST);
    if (wantR) play(T_FLIP);
  }
}

// ---- Lamps --------------------------------------------------------------

unsigned long lampsAt = 0;

uint32_t dim(uint32_t c) { return (c >> 3) & 0x1f1f1f; }

void serviceLamps() {
  unsigned long now = millis();
  if (now - lampsAt < 40) return;
  lampsAt = now;
  bool blink = (now / 250) % 2 == 0;
  lamps.clear();
  if (state == ATTRACT || state == GAME_OVER) {
    // Attract mode: a rainbow running around the table.
    for (int i = 0; i < LAMPS; i++) {
      lamps.setPixelColor(i, Adafruit_NeoPixel::ColorHSV((uint16_t)(now * 40 + i * 4096), 255, 160));
    }
  } else if (state == ENTRY) {
    for (int i = 0; i < LAMPS; i++) {
      if ((i + now / 120) % 4 == 0) lamps.setPixelColor(i, 255, 200, 0);
    }
  } else {
    uint32_t amber = Adafruit_NeoPixel::Color(255, 140, 0);
    for (int i = 0; i < 3; i++) lamps.setPixelColor(L_TARGET + i, targetLit[i] ? amber : dim(amber));
    for (int b = 0; b < 2; b++) {
      bool flash = now < bumperFlash[b];
      lamps.setPixelColor(L_BUMPER + b, flash ? 0xffffff : 0x202040);
    }
    for (int m = 2; m <= 5; m++) {
      lamps.setPixelColor(L_MULT + m - 2, multiplier >= m ? 0x00ff40 : 0);
    }
    bool saving = state == LAUNCHING || (state == PLAY && now - launchedAt < BALL_SAVE_MS);
    if (saving && blink) lamps.setPixelColor(L_SAVE, 0x00c0ff);
    for (int i = 0; i < 3; i++) {
      if (i < ballsLeft - (state == DRAINING ? 0 : 1)) lamps.setPixelColor(L_BALLS + i, 0xff0000);
    }
    if (state == LAUNCHING) {
      int lit = (now / 150) % 3;
      lamps.setPixelColor(L_ARROW + lit, 0xffffff);
    }
  }
  lamps.show();
}

// ---- The state machine ---------------------------------------------------

void attract() {
  // Last score, then the high score, then whose it is, two seconds each.
  int phase = ((millis() - stateAt) / 2000) % 3;
  if (phase == 0) {
    if (lastScore >= 0) showScore(lastScore);
    else showMessage("PLAY");
  } else if (phase == 1) {
    showScore(highScore);
  } else {
    char text[5] = {highName[0], highName[1], highName[2], ' ', 0};
    showMessage(text);
  }
  if (pressedEdge[SW_START] || pressedEdge[SW_LAUNCH]) startGame();
}

void launching() {
  if (pressedEdge[SW_LAUNCH]) {
    launchedAt = millis();
    play(T_LAUNCH);
    showScore(score);
    Serial.println("launch!");
    enter(PLAY);
  }
}

void playing() {
  for (int t = 0; t < 3; t++) {
    if (pressedEdge[SW_TA + t]) hitTarget(t);
  }
  for (int b = 0; b < 2; b++) {
    if (pressedEdge[SW_B1 + b]) hitBumper(b);
  }
  showScore(score);
  if (drainEdge) {
    if (millis() - launchedAt < BALL_SAVE_MS) {
      // Ball save: a drain straight off the launch is not the player's fault.
      play(T_SAVE);
      Serial.println("ball saved: shoot again");
      showMessage("SAUE");
      enter(LAUNCHING);
    } else {
      loseBall();
    }
  }
}

void draining() {
  if (millis() - stateAt < 1500) return;
  if (ballsLeft > 0) {
    newBall();
    return;
  }
  lastScore = score;
  Serial.print("game over: ");
  Serial.println(score);
  if (score > highScore) {
    play(T_HIGH);
    for (int i = 0; i < 3; i++) entry[i] = 'A';
    entryPos = 0;
    Serial.println("new high score! flippers pick a letter, START keeps it");
    enter(ENTRY);
  } else {
    play(T_OVER);
    showMessage("End ");
    enter(GAME_OVER);
  }
}

void gameOver() {
  // The final score blinks for a few seconds before the table goes back to attract.
  unsigned long t = millis() - stateAt;
  if (t > 1200) {
    if ((t / 400) % 2) showScore(score);
    else showMessage("    ");
  }
  if (t > 5000) enter(ATTRACT);
  if (t > 600 && pressedEdge[SW_START]) startGame();
}

int letterIndex(char c) {
  for (int i = 0; LETTERS[i]; i++) {
    if (LETTERS[i] == c) return i;
  }
  return 0;
}

void entering() {
  int count = (int)sizeof(LETTERS) - 1;
  int at = letterIndex(entry[entryPos]);
  if (pressedEdge[SW_FLIP_L]) at = (at + count - 1) % count;
  if (pressedEdge[SW_FLIP_R]) at = (at + 1) % count;
  if (pressedEdge[SW_FLIP_L] || pressedEdge[SW_FLIP_R]) play(T_CLICK);
  entry[entryPos] = LETTERS[at];
  if (pressedEdge[SW_START] || pressedEdge[SW_LAUNCH]) {
    play(T_TARGET);
    entryPos++;
    if (entryPos == 3) {
      saveHighScore();
      enter(ATTRACT);
      return;
    }
  }
  // The letter being chosen blinks.
  char text[5] = {entry[0], entry[1], entry[2], ' ', 0};
  if ((millis() / 250) % 2) text[entryPos] = ' ';
  showMessage(text);
}

void setup() {
  for (int i = 0; i < SWITCHES; i++) pinMode(SWITCH_PIN[i], INPUT_PULLUP);
  pinMode(DRAIN, INPUT);
  pinMode(KICKER, OUTPUT);
  digitalWrite(KICKER, LOW);
  Serial.begin(115200);
  lamps.begin();
  lamps.setBrightness(120);
  display.setBrightness(5);
  flipperL.attach(SERVO_L);
  flipperR.attach(SERVO_R);
  flipperL.write(L_REST);
  flipperR.write(R_REST);
  loadHighScore();
  Serial.print("Pinball ready. High score ");
  Serial.print(highName);
  Serial.print(" ");
  Serial.println(highScore);
  Serial.println("press START");
  enter(ATTRACT);
}

void loop() {
  readSwitches();
  serviceFlippers();
  switch (state) {
    case ATTRACT: attract(); break;
    case LAUNCHING: launching(); break;
    case PLAY: playing(); break;
    case DRAINING: draining(); break;
    case GAME_OVER: gameOver(); break;
    case ENTRY: entering(); break;
  }
  if (kickUntil != 0 && millis() >= kickUntil) {
    digitalWrite(KICKER, LOW);
    kickUntil = 0;
  }
  serviceSound();
  serviceLamps();
}

Parts list

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

How it is wired

22 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.

  • Ground: Arduino Mega 2560 R3 pin GND; Piezo speaker pin 2
  • Arduino Mega 2560 R3 pin 12; Piezo speaker pin 1
  • Arduino Mega 2560 R3 pin 6; Addressable RGB LEDs pin DIN
  • Arduino Mega 2560 R3 pin 3; Four-digit display, TM1637 pin CLK
  • Arduino Mega 2560 R3 pin 2; Four-digit display, TM1637 pin DIO
  • Arduino Mega 2560 R3 pin 5V; Four-digit display, TM1637 pin VCC; Addressable RGB LEDs pin VDD; Photo interrupter, slot pin VCC; Hobby servo (1) pin VCC; Hobby servo (2) pin VCC
  • Arduino Mega 2560 R3 pin 23; Photo interrupter, slot pin SIG
  • Arduino Mega 2560 R3 pin 25; Limit switch, lever microswitch (5) pin NO
  • Arduino Mega 2560 R3 pin 27; Limit switch, lever microswitch (4) pin NO
  • Arduino Mega 2560 R3 pin 29; MOSFET pin G
  • Arduino Mega 2560 R3 pin 31; Limit switch, lever microswitch (3) pin NO
  • Arduino Mega 2560 R3 pin 33; Limit switch, lever microswitch (2) pin NO
  • Arduino Mega 2560 R3 pin 35; Limit switch, lever microswitch (1) pin NO
  • Arduino Mega 2560 R3 pin 41; Arcade button (4) pin 1
  • Arduino Mega 2560 R3 pin 43; Hobby servo (2) pin PWM
  • Arduino Mega 2560 R3 pin 45; Arcade button (3) pin 1
  • Arduino Mega 2560 R3 pin 47; Arcade button (2) pin 1
  • Arduino Mega 2560 R3 pin 49; Hobby servo (1) pin PWM
  • Arduino Mega 2560 R3 pin 51; Arcade button (1) pin 1
  • Ground: Arduino Mega 2560 R3 pin GND; Four-digit display, TM1637 pin GND; Addressable RGB LEDs pin VSS; Limit switch, lever microswitch (1) pin COM; Limit switch, lever microswitch (2) pin COM; Limit switch, lever microswitch (3) pin COM; Limit switch, lever microswitch (4) pin COM; Limit switch, lever microswitch (5) pin COM; Photo interrupter, slot pin GND; MOSFET pin S; Bench power supply pin V-; Arcade button (1) pin 2; Hobby servo (1) pin GND; Arcade button (2) pin 2; Arcade button (3) pin 2; Hobby servo (2) pin GND; Arcade button (4) pin 2
  • MOSFET pin D; Diode pin A; Solenoid pin 1
  • Diode pin C; Solenoid pin 2; Bench power supply pin V+

Change it and keep it

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