Ever wondered just how much work it takes to write your own retro games? How easy is Pong to code for the Arduino? Join me as I show you how to build an Arduino powered mini retro games console, and how to code Pong from scratch. Here's the end result:

Build Plan

This is a fairly simple circuit. A potentiometer (pot) will control the game, and an OLED display will be driven by the Arduino. This will be produced on a breadboard, however you may wish to make this a permanent circuit and install it in a case. We've written about recreating Pong before, however today I will be showing you how to write the code from scratch, and breaking down every part.

What You Need

Retro Arduino Setup

Here's what you need:

  • 1 x Arduino (any model)
  • 1 x 10k Potentiometer
  • 1 x 0.96" I2C OLED Display
  • 1 x Breadboard
  • Assorted male > male hookup wires

Any Arduino should work, so look at our buying guide if you are not sure what model to buy.

These OLED displays are very cool. They can usually be purchased in white, blue, yellow, or a mixture of the three. They do exist in full color, however these add a whole other level to the complexity and cost of this project.

The Circuit

This is quite a simple circuit. If you don't have much experience with Arduino, check out these beginners projects first.

Here it is:

Pong Breadboard

Looking at the front of the pot, connect the left pin to +5V and the right pin to ground. Connect the middle pin to analog pin 0 (A0).

The OLED display is connected using the I2C protocol. Connect VCC and GND to the Arduino +5V and ground. Connect SCL to analog five (A5). Connect SDA to analog 4 (A4). The reason this is connected to the analog pins is simple; these pins contain the circuitry required for the I2C Protocol. Ensure these are connected correctly, and not crossed over. The exact pins will vary by model, but A4 and A5 are used on the Nano and Uno. Check the Wire library documentation for your model if you're not using an Arduino or Nano.

Pot Test

Upload this test code (make sure to select the correct board and port from the Tools > Board and Tools > Port menus):

        void setup() {
    // put your setup code here, to run once:
    Serial.begin(9600); // setup serial
}

void loop() {
    // put your main code here, to run repeatedly:
    Serial.println(analogRead(A0)); // print the value from the pot
    delay(500);
}

Now open the serial monitor (Top Right > Serial Monitor) and turn the pot. You should see the value displayed on the serial monitor. Fully counter-clockwise should be zero, and fully clockwise should be 1023:

Pong serial monitor

You will adjust this later on, but for now it's fine. If nothing happens, or the value changes without you doing anything, disconnect and double check the circuit.

OLED Test

OLED Graphics

The OLED display is slightly more complex to configure. You need to install two libraries to drive the display first. Download the Adafruit_SSD1306 and Adafruit-GFX libraries from Github. Copy the files into your libraries folder. This varies depending on your operating system:

  • Mac OS: /Users/Username/Documents/Arduino/libraries
  • Linux: /home/Username/Sketchbook
  • Windows: /Users/Arduino/libraries

Now upload a test sketch. Go to File > Examples > Adafruit SSD1306 > ssd1306_128x64_i2c. This should give you a large sketch containing lots of graphics:

OLED Graphics

If nothing happens after uploading, disconnect and double check your connections. If the examples are not in the menus, you may need to restart your Arduino IDE.

The Code

Now it's time for the code. I will be explaining every step, so skip to the end if you just want to get it running. This is a fair amount of code, so if you don't feel confident, check out these 10 free resources to learn to code.

Start by including the necessary libraries:

        #include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

SPI and WIRE are two Arduino libraries for handling the I2C communication. Adafruit_GFX and Adafruit_SSD1306 are the libraries you installed previously.

Next, configure the display:

        Adafruit_SSD1306 display(4);
    

Then setup all the variables needed to run the game:

        int resolution[2] = {128, 64}, ball[2] = {20, (resolution[1] / 2)};
const int PIXEL_SIZE = 8, WALL_WIDTH = 4, PADDLE_WIDTH = 4, BALL_SIZE = 4, SPEED = 3;
int playerScore = 0, aiScore = 0, playerPos = 0, aiPos = 0;
char ballDirectionHori = 'R', ballDirectionVerti = 'S';
boolean inProgress = true;

These store all the data necessary to run the game. Some of these store the location of the ball, the size of the screen, the location of the player and so on. Notice how some of these are const meaning they are constant, and will never change. This lets the Arduino compiler speed things up bit.

The screen resolution and ball location are stored in arrays. Arrays are collections of similar things, and for the ball, store the coordinates (X and Y). Accessing elements in arrays is easy (don't include this code in your file):

        resolution[1];
    

As arrays start at zero, this will return the second element in the resolution array (64). Updating elements is even easier (again, don't include this code):

        ball[1] = 15;
    

Inside void setup(), configure the display:

        void setup()   {
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.display();
}

The first line tells the Adafruit library what dimensions and communications protocol your display is using (in this case, 128 x 64 and I2C). The second line (display.display()) tells the screen to show whatever is stored in the buffer (which is nothing).

Create two methods called drawBall and eraseBall:

        void drawBall(int x, int y) {
  display.drawCircle(x, y, BALL_SIZE, WHITE);
}

void eraseBall(int x, int y) {
  display.drawCircle(x, y, BALL_SIZE, BLACK);
}

These take the x and y coordinates of the ball and draw it on the screen using the drawCircle method from the display libraries. This uses the constant BALL_SIZE defined earlier. Try changing this and see what happens. This drawCircle method accepts a pixel color -- BLACK or WHITE. As this is a monochromatic display (one color), white equates to a pixel being on, and black turns the pixel off.

Now create a method called moveAi:

        void moveAi() {
  eraseAiPaddle(aiPos);
  if (ball[1] > aiPos) {
    ++aiPos;
  }
  else if (ball[1] < aiPos) {
    --aiPos;
  }
  drawAiPaddle(aiPos);
}

This method handles moving the Artificial Intelligence or AI player. This is quite a simple computer opponent -- If the ball is above the paddle, move up. It it is below the paddle, move down. Quite simple, but it works well. The increment and decrement symbols are used (++aiPos and --aiPos) to add or subtract one from the aiPosition. You could add or subtract a larger number to make the AI move faster, and therefore be more difficult to beat. Here's how you would do that:

        aiPos += 2;
    

And:

        aiPos -= 2;
    

The Plus Equals and Minus Equals signs are shorthand for adding or subtracting two from/to the current value of aiPos. Here's another way to do that:

        aiPos = aiPos + 2;
    

and

        aiPos = aiPos - 1;
    

Notice how this method first erases the paddle, and then draws it again. This has to be done like this. If the new position of the paddle was drawn, there would be two overlapping paddles on the screen.

The drawNet method uses two loops to draw the net:

        void drawNet() {
  for (int i = 0; i < (resolution[1] / WALL_WIDTH); ++i) {
    drawPixel(((resolution[0] / 2) - 1), i * (WALL_WIDTH) + (WALL_WIDTH * i), WALL_WIDTH);
  }
}

This uses the WALL_WIDTH variables to set it's size.

Create methods called drawPixels and erasePixels. Just like the ball methods, the only difference between these two is the color of the pixels:

        void drawPixel(int posX, int posY, int dimensions) {
  for (int x = 0; x < dimensions; ++x) {
    for (int y = 0; y < dimensions; ++y) {
      display.drawPixel((posX + x), (posY + y), WHITE);
    }
  }
}

void erasePixel(int posX, int posY, int dimensions) {
  for (int x = 0; x < dimensions; ++x) {
    for (int y = 0; y < dimensions; ++y) {
      display.drawPixel((posX + x), (posY + y), BLACK);
    }
  }
}

Again, both of these methods use two for loops to draw a group of pixels. Rather than having to draw every pixel using the libraries drawPixel method, the loops draw a group of pixels based on the given dimensions.

The drawScore method uses the text features of the library to write the player and AI score to the screen. These are stored in playerScore and aiScore:

        void drawScore() {
  display.setTextSize(2);
  display.setTextColor(WHITE);
  display.setCursor(45, 0);
  display.println(playerScore);

  display.setCursor(75, 0);
  display.println(aiScore);
}

This method also has a eraseScore counterpart, that sets the pixels to black or off.

The final four methods are very similar. They draw and erase the player and AI paddles:

        void erasePlayerPaddle(int row) {
  erasePixel(0, row - (PADDLE_WIDTH * 2), PADDLE_WIDTH);
  erasePixel(0, row - PADDLE_WIDTH, PADDLE_WIDTH);
  erasePixel(0, row, PADDLE_WIDTH);
  erasePixel(0, row + PADDLE_WIDTH, PADDLE_WIDTH);
  erasePixel(0, row + (PADDLE_WIDTH + 2), PADDLE_WIDTH);
}

Notice how they call the erasePixel method create earlier. These methods draw and erase the appropriate paddle.

There's a bit more logic in the main loop. Here's the whole code:

        #include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

Adafruit_SSD1306 display(4);

int resolution[2] = {128, 64}, ball[2] = {20, (resolution[1] / 2)};
const int PIXEL_SIZE = 8, WALL_WIDTH = 4, PADDLE_WIDTH = 4, BALL_SIZE = 4, SPEED = 3;
int playerScore = 0, aiScore = 0, playerPos = 0, aiPos = 0;
char ballDirectionHori = 'R', ballDirectionVerti = 'S';
boolean inProgress = true;

void setup() {
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.display();
}

void loop() {
  if (aiScore > 9 || playerScore > 9) {
    // check game state
    inProgress = false;
  }

  if (inProgress) {
    eraseScore();
    eraseBall(ball[0], ball[1]);

    if (ballDirectionVerti == 'U') {
      // move ball up diagonally
      ball[1] = ball[1] - SPEED;
    }

    if (ballDirectionVerti == 'D') {
      // move ball down diagonally
      ball[1] = ball[1] + SPEED;
    }

    if (ball[1] <= 0) { // bounce the ball off the top ballDirectionVerti = 'D'; } if (ball[1] >= resolution[1]) {
      // bounce the ball off the bottom
      ballDirectionVerti = 'U';
    }

    if (ballDirectionHori == 'R') {
      ball[0] = ball[0] + SPEED; // move ball
      if (ball[0] >= (resolution[0] - 6)) {
        // ball is at the AI edge of the screen
        if ((aiPos + 12) >= ball[1] && (aiPos - 12) <= ball[1]) { // ball hits AI paddle if (ball[1] > (aiPos + 4)) {
            // deflect ball down
            ballDirectionVerti = 'D';
          }
          else if (ball[1] < (aiPos - 4)) {
            // deflect ball up
            ballDirectionVerti = 'U';
          }
          else {
            // deflect ball straight
            ballDirectionVerti = 'S';
          }
          // change ball direction
          ballDirectionHori = 'L';
        }
        else {
          // GOAL!
          ball[0] = 6; // move ball to other side of screen
          ballDirectionVerti = 'S'; // reset ball to straight travel
          ball[1] = resolution[1] / 2; // move ball to middle of screen
          ++playerScore; // increase player score
        }
      }
    }

    if (ballDirectionHori == 'L') {
      ball[0] = ball[0] - SPEED; // move ball
      if (ball[0] <= 6) { // ball is at the player edge of the screen if ((playerPos + 12) >= ball[1] && (playerPos - 12) <= ball[1]) { // ball hits player paddle if (ball[1] > (playerPos + 4)) {
            // deflect ball down
            ballDirectionVerti = 'D';
          }
          else if (ball[1] < (playerPos - 4)) { // deflect ball up ballDirectionVerti = 'U'; } else { // deflect ball straight ballDirectionVerti = 'S'; } // change ball direction ballDirectionHori = 'R'; } else { ball[0] = resolution[0] - 6; // move ball to other side of screen ballDirectionVerti = 'S'; // reset ball to straight travel ball[1] = resolution[1] / 2; // move ball to middle of screen ++aiScore; // increase AI score } } } drawBall(ball[0], ball[1]); erasePlayerPaddle(playerPos); playerPos = analogRead(A2); // read player potentiometer playerPos = map(playerPos, 0, 1023, 8, 54); // convert value from 0 - 1023 to 8 - 54 drawPlayerPaddle(playerPos); moveAi(); drawNet(); drawScore(); } else { // somebody has won display.clearDisplay(); display.setTextSize(4); display.setTextColor(WHITE); display.setCursor(0, 0); // figure out who if (aiScore > playerScore) {
      display.println("YOU LOSE!");
    }
    else if (playerScore > aiScore) {
      display.println("YOU WIN!");
    }
  }

  display.display();
}

void moveAi() {
  // move the AI paddle
  eraseAiPaddle(aiPos);
  if (ball[1] > aiPos) {
    ++aiPos;
  }
  else if (ball[1] < aiPos) {
    --aiPos;
  }
  drawAiPaddle(aiPos);
}

void drawScore() {
  // draw AI and player scores
  display.setTextSize(2);
  display.setTextColor(WHITE);
  display.setCursor(45, 0);
  display.println(playerScore);

  display.setCursor(75, 0);
  display.println(aiScore);
}

void eraseScore() {
  // erase AI and player scores
  display.setTextSize(2);
  display.setTextColor(BLACK);
  display.setCursor(45, 0);
  display.println(playerScore);

  display.setCursor(75, 0);
  display.println(aiScore);
}

void drawNet() {
  for (int i = 0; i < (resolution[1] / WALL_WIDTH); ++i) {
    drawPixel(((resolution[0] / 2) - 1), i * (WALL_WIDTH) + (WALL_WIDTH * i), WALL_WIDTH);
  }
}

void drawPixel(int posX, int posY, int dimensions) {
  // draw group of pixels
  for (int x = 0; x < dimensions; ++x) {
    for (int y = 0; y < dimensions; ++y) {
      display.drawPixel((posX + x), (posY + y), WHITE);
    }
  }
}

void erasePixel(int posX, int posY, int dimensions) {
  // erase group of pixels
  for (int x = 0; x < dimensions; ++x) {
    for (int y = 0; y < dimensions; ++y) {
      display.drawPixel((posX + x), (posY + y), BLACK);
    }
  }
}

void erasePlayerPaddle(int row) {
  erasePixel(0, row - (PADDLE_WIDTH * 2), PADDLE_WIDTH);
  erasePixel(0, row - PADDLE_WIDTH, PADDLE_WIDTH);
  erasePixel(0, row, PADDLE_WIDTH);
  erasePixel(0, row + PADDLE_WIDTH, PADDLE_WIDTH);
  erasePixel(0, row + (PADDLE_WIDTH + 2), PADDLE_WIDTH);
}

void drawPlayerPaddle(int row) {
  drawPixel(0, row - (PADDLE_WIDTH * 2), PADDLE_WIDTH);
  drawPixel(0, row - PADDLE_WIDTH, PADDLE_WIDTH);
  drawPixel(0, row, PADDLE_WIDTH);
  drawPixel(0, row + PADDLE_WIDTH, PADDLE_WIDTH);
  drawPixel(0, row + (PADDLE_WIDTH + 2), PADDLE_WIDTH);
}

void drawAiPaddle(int row) {
  int column = resolution[0] - PADDLE_WIDTH;
  drawPixel(column, row - (PADDLE_WIDTH * 2), PADDLE_WIDTH);
  drawPixel(column, row - PADDLE_WIDTH, PADDLE_WIDTH);
  drawPixel(column, row, PADDLE_WIDTH);
  drawPixel(column, row + PADDLE_WIDTH, PADDLE_WIDTH);
  drawPixel(column, row + (PADDLE_WIDTH * 2), PADDLE_WIDTH);
}

void eraseAiPaddle(int row) {
  int column = resolution[0] - PADDLE_WIDTH;
  erasePixel(column, row - (PADDLE_WIDTH * 2), PADDLE_WIDTH);
  erasePixel(column, row - PADDLE_WIDTH, PADDLE_WIDTH);
  erasePixel(column, row, PADDLE_WIDTH);
  erasePixel(column, row + PADDLE_WIDTH, PADDLE_WIDTH);
  erasePixel(column, row + (PADDLE_WIDTH * 2), PADDLE_WIDTH);
}

void drawBall(int x, int y) {
  display.drawCircle(x, y, BALL_SIZE, WHITE);
}

void eraseBall(int x, int y) {
  display.drawCircle(x, y, BALL_SIZE, BLACK);
}

Here's what you end up with:

OLED Pong

Once you are confident with the code, there are numerous modifications you can make:

  • Add a menu for difficulty levels (change AI and ball speed).
  • Add some random movement to the ball or AI.
  • Add another pot for two player.
  • Add a pause button.

Now take a look at these retro gaming Pi Zero projects.

Have you coded Pong using this code? What modifications did you make? Let me know in the comments below, I'd love to seem some pictures!