3

I'm working on a tiny ping-pong game based on Arduino Nano. Its modes are "person vs person" and "person vs computer" (the computer just tries to keep its racket at the same Y position as the ball). There are 2 "up" buttons, 2 "down" buttons, two 7-segment score indicators and 2 shift registers controlling them. The display is SSD1306 (128x32 pixels).

I'm posting here the entire code because I don't know where the issue lies.

#include <Adafruit_SSD1306.h>

// Up/down buttons
#define UP1 2  
#define DOWN1 3
#define UP2 4
#define DOWN2 5

// Shift registers that control score indicators
#define DATA1 6
#define CLOCK1 7
#define LATCH1 8
#define DATA2 9
#define CLOCK2 10
#define LATCH2 11

#define RANDOM_NUMBERS A0  // This port isn't connected to anything and is used to get random seed
#define WRAP 118  // Text on display wraps on this position

#define SSD1306_ADDRESS 0x3C  // I2C address of display

Adafruit_SSD1306 ssd1306;

const byte numbers[10] = {  // Arab numbers for score indicators
  0b1111110, 0b0011000, 0b1101101, 0b0111101, 0b0011011, 0b0110111, 0b1110111, 0b0011100, 0b1111111, 0b0111111
};

short currentDirection = -1;
short directionState = -1;  // The sequence number of next move in the period of ball trajectory
short nextMove = -1;
short ballX = 63;
short ballY = 15;
short racket1 = 12;
short racket2 = 12;

short score1 = 0;
short score2 = 0;
bool gameOver = false;

bool playingWithComputer = false;

void setup() {
  pinMode(UP1, INPUT_PULLUP);
  pinMode(DOWN1, INPUT_PULLUP);
  pinMode(UP2, INPUT_PULLUP);
  pinMode(DOWN2, INPUT_PULLUP);
  pinMode(DATA1, OUTPUT);
  pinMode(CLOCK1, OUTPUT);
  pinMode(LATCH1, OUTPUT);
  pinMode(DATA2, OUTPUT);
  pinMode(CLOCK2, OUTPUT);
  pinMode(LATCH2, OUTPUT);
  
  randomSeed(analogRead(RANDOM_NUMBERS));

  updateScores();

  ssd1306.begin(SSD1306_SWITCHCAPVCC, SSD1306_ADDRESS);
  ssd1306.display();
  delay(2000);  // A delay to show the Adafruit logo
  ssd1306.clearDisplay();

  ssd1306.setTextSize(1);
  ssd1306.setTextColor(WHITE);
  ssd1306.setCursor(0, 0);  // Cursor should be at this position to draw objects correctly

  printLongText(5, 0, "Press \"up\" to play with computer, otherwise press \"down\"");

  while (true) {
    if (!digitalRead(UP1) || !digitalRead(UP2)) {  // Both "up" buttons (as well as both "down" ones) are treated the same unless there's a game "person vs person"
      playingWithComputer = true;
      break;
    }
    if (!digitalRead(DOWN1) || !digitalRead(DOWN2)) break;

    delay(5);  // This delay is here to avoid overloading the processor
  }

  ssd1306.clearDisplay();
  ssd1306.display();
  
  delay(100);  // This delay is intended to show user that they don't need to hold button any more
}

void loop() {
  if (gameOver) {
    delay(1000);
    return;
  }

  if (nextMove < 0) {
    pickDirection();
    return;
  }

  short oldDirection = currentDirection;
  switch (getBounceType()) {
    case 0:
      verticalFlipDirection();
      ballY++;
      break;
    case 1:
      verticalFlipDirection();
      ballY--;
      break;
    case 2:
      if (ballY < (racket1 - 1) || ballY > (racket1 + 8)) {  // This determines if the ball ran shorto the racket or not (true if it did not)
        score2++;

        // Ball goes to the center of the screen after goal
        ballX = 63;
        ballY = 15;
        pickDirection();

        updateScores();
      }
      else {
        horizontalFlipDirection();
        ballX++;
      }

      break;
    case 3:
      if (ballY < (racket2 - 1) || ballY > (racket2 + 8)) {
        score1++;
        ballX = 63;
        ballY = 15;
        pickDirection();
        updateScores();
      }
      else {
        horizontalFlipDirection();
        ballX--;
      }

      break;
    case 4:
      if (racket1 > 1) {
        score2++;
        ballX = 63;
        ballY = 15;
        pickDirection();
        updateScores();
      }
      else {
        reverseDirection();  // This occurs when the ball rans exactly shorto the corner of its zone
        ballX++;
        ballY++;
      }

      break;
    case 5:
      if (racket2 > 1) {
        score1++;
        ballX = 63;
        ballY = 15;
        pickDirection();
        updateScores();
      }
      else {
        ballX--;
        ballY++;
        reverseDirection();
      }

      break;
    case 6:
      if (racket1 > 1) {
        score2++;
        ballX = 63;
        ballY = 15;
        pickDirection();
        updateScores();
      }
      else {
        reverseDirection();
        ballX++;
        ballY--;
      }

      break;
    case 7:
      if (racket2 > 1) {
        score1++;
        ballX = 63;
        ballY = 15;
        pickDirection();
        updateScores();
      }
      else {
        ballX--;
        ballY--;
        reverseDirection();
      }

      break;
  }
  if (oldDirection != currentDirection) directionState = 0;  // Resetting direction state if the ball bounced
  pickMove();  // Next move may have changed if the ball bounced
  
  switch (nextMove) {
    case 0:
      ballY--; 
      break;
    case 1:
      ballX++;
      break;
    case 2:
      ballY++;
      break;
    case 3:
      ballX--;
      break;
  }
  directionState++;
  pickMove();

  ssd1306.clearDisplay();
  ssd1306.drawRect(ballX, ballY, 2, 2, WHITE);

  if (playingWithComputer) {
    if (!digitalRead(UP1) || !digitalRead(UP2)) {
      if (racket1 > 0) racket1--;
    }
    if (!digitalRead(DOWN1) || !digitalRead(DOWN2)) {
      if (racket1 < 24) racket1++;
    }

    // This is the algorithm used by computer — it just tries to keep the computer-controlled racket at the same Y position as the ball
    if ((racket2 + 3) > ballY) {
      if (racket2 > 0) racket2--;
    }
    if ((racket2 + 3) < ballY) {
      if (racket2 < 24) racket2++;
    }
  } else {
    if (!digitalRead(UP1)) {
      if (racket1 > 0) racket1--;
    }
    if (!digitalRead(DOWN1)) {
      if (racket1 < 24) racket1++;
    }
    if (!digitalRead(UP2)) {
      if (racket2 > 0) racket2--;
    }
    if (!digitalRead(DOWN2)) {
      if (racket2 < 24) racket2++;
    }
  }

  ssd1306.drawRect(0, racket1, 2, 8, WHITE);
  ssd1306.drawRect(126, racket2, 2, 8, WHITE);
  
  ssd1306.display();
}

// This function picks a random direction for the ball
void pickDirection() {
  currentDirection = random(0, 20);
  directionState = 0;
  pickMove();
}

// This function determines the next move of the ball (up/right/down/left) based on its current direction and direction state
void pickMove() {
  short sector = getSector();  // Directions that go to up-right are in sector 0, those going to down-right are in sector 1, etc

  switch (currentDirection) {
    case 0:
    case 5:
    case 10:
    case 15:
      directionState = directionState % 5;  // Resetting direction state when the ball reaches next period of its trajectory

      // At certain direction states the ball goes to its primary direction (e.g. in an almost vertical trajectory the primary direction would be up)
      // At other states it goes to the secondary direction, which when added to the primary one forms the actual trajectory of the ball
      // Sectors divide into 2 parts. The first part includes 3 trajectories which have their primary direction number (up - 0, right - 1, down - 2, left - 3) same as sector number
      // Secondary directions in first part are sector number plus 1
      // In the second part the primary direction is (sector_number + 1) % 4 and the secondary one is the sector number
      // There are 20 supported trajectories (5 in each of 4 sectors). They are presented in Ping-Pong.png
      // First trajectories in each sector (as well as second ones etc) have the same structure, so there's only one code block
      // for handling first trajectories of all sectors. However, their primary and secondary directions differ in each sector
      // The following line determines if the ball should now go to the primary or secondary direction and based on that sets nextMove
      nextMove = (directionState == 0 || directionState == 1 || directionState == 3) ? sector : (sector + 1) % 4;
      return;
    case 1:
    case 6:
    case 11:
    case 16:
      directionState = directionState % 4;
      nextMove = (directionState < 2) ? sector : (sector + 1) % 4;
      return;
    case 2:
    case 7:
    case 12:
    case 17:
      directionState = directionState % 4;
      nextMove = (directionState == 0 || directionState == 2) ? sector : (sector + 1) % 4;
      return;
    case 3:
    case 8:
    case 13:
    case 18:
      directionState = directionState % 4;
      nextMove = (directionState < 2) ? getSecondPartMove(sector) : sector;
      return;
    case 4:
    case 9:
    case 14:
    case 19:
      directionState = directionState % 5;
      nextMove = (directionState == 0 || directionState == 1 || directionState == 3) ? getSecondPartMove(sector) : sector;
      return;
  }
}

// Just a helper for pickMove (determines primary direction of second-part trajectories in a certain sector) 
short getSecondPartMove(short sector) {
  return (sector + 1) % 4;
}

// This function determines the current bounce type (-1 - currently no bounce, 0 - bounce from upper wall,
// 1 - bounce from lower wall, 2 - facing the first player's wall, 3 - facing the second player's wall,
// 4 - facing upper-left corner, 5 - upper-right corner, 6 - bottom-left, 7 - bottom right
// On 4-7 the ball reverses its trajectory
short getBounceType() {
  if (ballX <= 2 && ballY <= 0) return 4;  // Made ballX indents so that the ball bounces from rackets and not bounds of screen
  if (ballX >= 124 && ballY <= 0) return 5;
  if (ballX <= 2 && ballY >= 30) return 6;
  if (ballX >= 124 && ballY >= 30) return 7;

  if (ballX <= 2) return 2;
  if (ballX >= 124) return 3;

  if (ballY <= 0) return 0;
  if (ballY >= 30) return 1;

  return -1;
}

// This function flips the current trajectory of the ball vertically to bounce from the upper/lower wall
// (source and flipped trajectories have a relationship that differs in different sectors of the source trajectory, see this function)
void verticalFlipDirection() {
  short sector = getSector();
  
  switch (sector) {
    case 0:
    case 1:
      currentDirection = 9 - currentDirection;
      return;
    case 2:
    case 3:
      currentDirection = 10 + (9 - (currentDirection - 10));
      return;
  }

  directionState = 0;
}

// Same as previous function but the translation is horizontal here
// (the relationship between source and flipped trajectories is different)
void horizontalFlipDirection() {
  short sector = getSector();

  switch (sector) {
    case 0:
    case 3:
      currentDirection = 19 - currentDirection;
      return;
    case 1:
    case 2:
      currentDirection = 5 + (9 - (currentDirection - 5));
      return;
  }

  directionState = 0;
}

// This function reverses the trajectory of the ball
void reverseDirection() {
  currentDirection = (currentDirection + 10) % 20;
  directionState = 0;
}

// This function determines the sector of the current trajectory
short getSector() {
  if (currentDirection < 5) return 0;
  else if (currentDirection < 10) return 1;
  else if (currentDirection < 15) return 2;
  else return 3;
}

// This function prints text on the display, wrapping it at a certain X position
void printLongText(short cursorX, short cursorY, String text) {
  ssd1306.setCursor(cursorX, cursorY);
  
  int16_t x, y;
  uint16_t w, h;

  short lastSpace = -1;
  short i;

  // Adding character by character to current line until the wrapping position is reacher
  // In parallel finding the last space before wrap (it will be where the line breaks)
  for (i = 0; i < text.length(); i++) {
    if (text.charAt(i) == ' ') lastSpace = i;

    ssd1306.getTextBounds(text.substring(0, i + 1), 0, 0, &x, &y, &w, &h);

    if (x + w > WRAP && lastSpace >= 0) {
      ssd1306.println(text.substring(0, lastSpace + 1));  // Wrapping position reached; breaking the current line at the index of last space in it and printing it
      text = text.substring(lastSpace + 1);

      i = -1;  // Now the same with next line
      lastSpace = -1;
      ssd1306.setCursor(cursorX, ssd1306.getCursorY());
    }
  }
  ssd1306.println(text);  // printing the rest of text

  ssd1306.display();

  ssd1306.setCursor(0, 0);  // Cursor should be at this position to draw objects correctly
}

// This function displays the scores on corresponding indicators and checks if the game if over
// (i.e. one of the players has a score of 7)
void updateScores() {
  digitalWrite(LATCH1, false);
  shiftOut(DATA1, CLOCK1, MSBFIRST, numbers[score1]);  // Sending the necessary mask to the shift registers
  digitalWrite(LATCH1, true);

  digitalWrite(LATCH2, false);
  shiftOut(DATA2, CLOCK2, MSBFIRST, numbers[score2]);
  digitalWrite(LATCH2, true);

  if (score1 >= 7) {
    ssd1306.clearDisplay();
    printLongText(10, 12, "Player 1 wins!");
    ssd1306.display();

    gameOver = true;
  }
  if (score2 >= 7) {
    ssd1306.clearDisplay();
    printLongText(10, 12, "Player 2 wins!");
    ssd1306.display();

    gameOver = true;
  }
}

Look how slowly does the ball move, even though a loop iteration doesn't include any delays at all, and there are no complex calculations. Conversion to GIF didn't change the speed.

My circuit looks like this (it's just a test, so no buttons, just pull-ups, and no score indicators).

schematic

simulate this circuit – Schematic created using CircuitLab

Answers here didn't help. Changing the I2C clock speed to 400 KHz didn't help either.

So — is such slowness caused by the (not very high) complexity of my code? Or is it some kind of glitch? Please don't suggest using libraries instead of my own moving and bouncing logic.

9
  • Have you tried checking, what exactly is taking so long on each loop() iteration by using millis() or micros()? My guess is, that the display code is taking rather long. You could also try to not clear the display on every iteration, but drawing over the previous rectangles with new black rectanges. Might work faster
    – chrisl
    Sep 1, 2023 at 23:17
  • 1
    I've reproduced this problem, as I happened to have one of these displays lying around. Commenting out the clear of the screen didn't help.
    – Nick Gammon
    Sep 1, 2023 at 23:27
  • The code is taking around 167 ms per loop iteration.
    – Nick Gammon
    Sep 1, 2023 at 23:31
  • 3
    Good question, BTW. You have described the problem well, shown the schematic, shown the code, described things that you have already tried, and explained in detail what the problem was. Plus you had a video of the problem. Overall an excellent job!
    – Nick Gammon
    Sep 2, 2023 at 0:58
  • Regarding your edit about SPI, the speed difference would be significant. According to my page about protocols SPI can be 30 times as fast as I2C (depending on the I2C clock speed) so I suggest that getting the SPI version of that board would indeed give you a significantly faster refresh rate. You may then need to rework how you show scores but bit banged SPI could be an option for the scores. The scores hardly need to update all that fast, and in any case there would be a lot less data.
    – Nick Gammon
    Sep 5, 2023 at 5:43

1 Answer 1

10

I put in some timing into your code. As a tip for you and others who may want to time code, here is a simple way of doing that ...

Timer class

Define a timer class as follows:

class timer
  {
  unsigned long start;
  const char sReason [50];

  public:

    // constructor remembers time it was constructed
    timer (const char * s)
      {
      strncpy (sReason, s, sizeof (sReason) - 1);
      start = micros ();
      Serial.print (F("Start:  "));
      Serial.println (sReason);
      };

    // destructor gets current time, displays difference
    ~timer ()
      {
      unsigned long t = micros ();
      Serial.print (F("Finish: "));
      Serial.print (sReason);
      Serial.print (F(" = "));
      Serial.print (t - start);
      Serial.println (F(" µs"));
      }
  };    // end of class timer

How to use the timer class

If you want to time "loop" then just put this at the start of it:

void loop() {
  timer t ("Loop");
  ... other code ...
}  // end of loop

The constructor starts timing, and the destructor (when loop is exited) works out the difference and displays it.

To time some specific code, make a "block" like this:

  {    // start of block
  timer t ("Draw ball");
  ssd1306.clearDisplay();
  ssd1306.drawRect(ballX, ballY, 2, 2, WHITE);
  }    // end of block

Inside the block the timer instance is created, it times what is inside the block, and displays the result after the } is passed.

Naturally, for all this to work you need to do a Serial.begin (115200); in setup.


Results of timing

I added the above two timing calls, plus for drawing the rackets and updating the display:

  {
  timer t ("Draw rackets");
  ssd1306.drawRect(0, racket1, 2, 8, WHITE);
  ssd1306.drawRect(126, racket2, 2, 8, WHITE);
  }

  {
  timer t ("ssd1306.display");
  ssd1306.display();
  }

The results I got were:

Start:  Loop
Start:  Draw ball
Finish: Draw ball = 1812 µs
Start:  Draw rackets
Finish: Draw rackets = 1956 µs
Start:  ssd1306.display
Finish: ssd1306.display = 168944 µs
Finish: Loop = 178516 µs

Clearly, updating the display is taking almost all the time (169 ms each time, which means you will be doing around 5 loops per second).

Following on from a suggestion in the Adafruit forum I changed your constructor for ssd1306 to be:

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
#define OLED_RESET -1
Adafruit_SSD1306 ssd1306(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

Now the timing is:

Start:  Loop
Start:  Draw ball
Finish: Draw ball = 1780 µs
Start:  Draw rackets
Finish: Draw rackets = 1956 µs
Start:  ssd1306.display
Finish: ssd1306.display = 21192 µs
Finish: Loop = 30648 µs

So that is around 33 FPS rather than 5 FPS. A big improvement!


Further speed improvements

Following on from your edit about changing from I2C to SPI "not making much difference", the speed difference would indeed be significant. According to my page about protocols SPI can be 30 times as fast as I2C (depending on the I2C clock speed) so I suggest that getting the SPI version of that board would indeed give you a significantly faster refresh rate — at least 10 times as fast.

You may then need to rework how you show scores but bit banged SPI could be an option for the them. The scores hardly need to update all that quickly, and in any case there would be a lot less data. (The screen has 4096 pixels, and the scores would only be a handful of digits).


From a comment by SNBS:

But I noted in my question that changing the I2C clock speed to 400 KHz (which is 4 times as fast as its regular speed) didn't affect the situation at all.

Yes, well the default behaviour for the constructor is to use a 400 kHz clock, see the constructor declaration:

Adafruit_SSD1306(uint8_t w, uint8_t h, TwoWire *twi = &Wire,
                   int8_t rst_pin = -1, uint32_t clkDuring = 400000UL,
                   uint32_t clkAfter = 100000UL);

The constructor definition saves clkDuring into wireClk.

Adafruit_SSD1306::Adafruit_SSD1306(uint8_t w, uint8_t h, TwoWire *twi,
                                   int8_t rst_pin, uint32_t clkDuring,
                                   uint32_t clkAfter)
    : Adafruit_GFX(w, h), spi(NULL), wire(twi ? twi : &Wire), buffer(NULL),
      mosiPin(-1), clkPin(-1), dcPin(-1), csPin(-1), rstPin(rst_pin)
#if ARDUINO >= 157
      ,
      wireClk(clkDuring), restoreClk(clkAfter)
#endif

At the start of the display function it calls TRANSACTION_START

void Adafruit_SSD1306::display(void) {
  TRANSACTION_START

This is a define:

#define TRANSACTION_START                                                      \
  if (wire) {                                                                  \
    SETWIRECLOCK;                                                              \
  } else {                                                                     \
    if (spi) {                                                                 \
      SPI_TRANSACTION_START;                                                   \
    }                                                                          \
    SSD1306_SELECT;                                                            \
  } ///< Wire, SPI or bitbang transfer setup

And SETWIRECLOCK sets the I2C clock speed for you:

#define SETWIRECLOCK wire->setClock(wireClk)    ///< Set before I2C transfer
3
  • Thank you for your work! Did it, the ball now moves quite fast, which was my goal.
    – SNBS
    Sep 2, 2023 at 17:24
  • 1
    A much faster frame rate should be achievable with I2C by updating only the parts of the display buffer that have actually changed since the previous frame. The SSD1306 supports this but, alas, the Adafruit_SSD1306 library doesn't. Sep 6, 2023 at 9:30
  • @EdgarBonet True, especially with something simple like Pong.
    – Nick Gammon
    Sep 6, 2023 at 21:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.