[...]

Week 3: Embedded Programming

Intro

Our assignment this week was to

  • browse through the data sheet for a microcontroller
  • write and test a program for an embedded system using a microcontroller to interact (with input &/or output devices) and communicate (with wired or wireless connections)
    • extra credit: assemble the system
    • extra credit: try different languages &/or development environments I’ve done a bit of embedded programming before, but this is my first time working with a Raspberry Pi product!

Soldering

Training

Before programming, we had to attach the OLED, the RP2040 Xiao module, and 2 10k resistors to the board. I attended a training with Quentin to learn how to solder safely and not inhale too many fumes. Here were my takeaways on how to solder:

  1. Turn on soldering iron
  2. Turn on vent fan and aim it at the board
  3. Place the board on the jig
  4. Angle soldering iron to touch the connector and component to heat both up, and feed solder + flux in
  5. If the board is messy, clean it with isopropyl alcohol + toothbrush because flux is acidic
    1. Wear gloves for this
    2. Wipe with tissue
  6. TURN OFF SOLDERING IRON, and make sure the other soldering irons in the room are off while you’re at it
  7. Turn off vent fan

Assembly

Then, I followed this helpful guide to solder the QPAD. The videos were helpful for recognizing the exact moment the flux attaches to a pad.

I used a 3D printed jig to hold the board down, soldered the resistors first. solder

Then, I soldered the Xiao, and finally the OLED screen. solder

Setup

Reading documentation

After assembling the board, the rest of the assignment was on the software side. It was helpful to read through the documentation before attempting to program the board. I found Quentin’s repo for the QPAD and noted the board pinout

  • P03: touch pad 0
  • P04: touch pad 1
  • P02: touch pad 2
  • P27: touch pad 3
  • P01: touch pad 4
  • P26: touch pad 5
  • P06: display SDA
  • P07: display SCL

Then, I found the RP2040 datasheet online, and started scanning through it. The entire datasheet was >600 pages, so I tried to only read through the relevant sections.

I thought this overview of the chip was helpful, showing the available peripherals, IOs, and memory layout. The SRAM was partitioned into 6 banks to allow for parallel processing.

embedded

The other sections that I skimmed through were: 2.5 DMA, 2.6 Memory, 2.19 GPIO, 2.19.3 Interrupts, and 3 PIO (supports I2C, SPI, DSPI, QSPI, UART, SDIO).

Toolchain, workflow

I decided to use an Arduino workflow because it seemed pretty common, which would hopefully make it easier to find documentation online. I also wanted to practice using C/C++. I installed the Arduino IDE and set up the Arduino core, which is an API to communicate with a specific set of chips, in this case, the Raspberry Pi Pico. I followed these instructions to set up the Arduino Board Manager.

Testing examples

To validate that my soldering/board assembly actually worked, I wanted to flash Quentin’s touch and screen examples to check the behavior. The steps are generally:

  1. Download the example file and open it in Arduino

  2. On the first time connecting, press BOOT while plugging the Xiao in to put the board in boot mode

  3. In Tools -> Board, select the Raspberry Pi and UF2 board

    embedded
  4. In Tools -> Port, select the correct port

  5. Press upload to flash the board

The first time that I tried this, I encountered an error saying “Adafruit_GFX.h: No such file or directory”. This meant that I didn’t have the necessary libraries installed. To fix this, go to Tools -> Manage Libraries, and install Adafruit GFX and Adafruit SSD1306 OLED library.

After doing this, the code successfully compiled and uploaded, but nothing happened on the screen. After checking, the UF2/RPI-RP2 port had disappeared, and I saw /dev/cu.usbmodem3101. It turns out that when the RP2040 is plugged in while in bootloader mode, the laptop sees it as a mass storage drive so that the .uf2 firmware file can be copied to it. When it’s flashed for the first time, the RP2040 exits bootloader mode, reboots, starts running the firmware, and exposes itself as a normal serial device (/dev/cu.usbmodem3101).

I selected the new port, tried uploading again, and received another error: “Timeout. The IDE has not received the ‘success’ message from the monitor after successfully connecting to it. Could not connect to /dev/cu.usbmodem3101 serial port”. In case there was an issue with Quentin’s example, I tried flashing a default blink example, and still nothing happened. Then, I referenced the Seeed Studio Xiao Arduino documentation, and noticed that after selecting the Raspberry Pi board, I had to open an additional menu and select the specific Seeed XiAO RP2040 module.

embedded

After doing this, flashing the examples worked! I first flashed the display test, which displays “Hello world!” on the OLED.

embedded

Then, I flashed the touch test, which prints out values to the serial monitor when each pad is touched.

Sugar, Sugar

For my project, I tried to think of games which a simple movement mechanism (like WASD), to use the 4 touch pads. I also thought it would be fun to let the user draw lines using the pads. I thought back to some of my favorite games as a kid, and landed on Sugar, Sugar. The basic premise is that sugar is falling from the top of the screen, and you have to draw lines to guide the sugar into cups. The levels progressively get more difficult, and new mechanisms, like flipping the gravity so the sugar falls up, instead of down, become available.ffmpeg -i single.mov -vcodec libx265 -crf 28 -acodec aac -b:a 128k comp/single.mp4

Coding

I used ChatGPT to write the embedded code for the game, and edited it manually after the base code was generated. I added Quentin’s display and touch examples to the prompt, and also input the pinout of the board. I also input a basic description of the game, well as data structures that I thought would be helpful (list of structs for each Sugar molecule, list of structs for Cups), and the behavior for each touch pad.

After testing that the base game worked, I used ChatGPT to make a v2 with more functionality, including adding more levels, inverted gravity, a menu screen, a reset button, and a controls screen. I also thought it would be cute to add a little loading screen, in the style of the original Sugar, Sugar game.

embedded embedded

Memory limitations

Since there is a limited amount of dynamic memory, I had to recycle the sugar particles instead of creating new ones. When the sugar particle leaves the screen or is stationary for >2 seconds (meaning it is stuck on a drawn line), the position is reset to the origin location.

Finished game

The final code is below. Much of the screen/touch portion is referenced from Quentin’s examples. The main parts of the behavior are:

  • Sugar: struct for each sugar grain that keeps track of the molecule’s position and velocity
  • Cup: struct for each cup that keeps track of the height/width, total capacity, current fullness, and whether it’s upside down or not
  • Levels: utilizes helper functions to place cups and set the sugar origin location for each level
  • Touch: reads which pads are currently touched and updates the pin_touched_now array
  • Setup: wire the pins, set a random seed for the sugar, and initialize the OLED
  • Main loop: check if pins are touched, draw the menu or control screen if it’s currently open, handle behavior if any of the pads are touched, update the game drawing (sugar), check if level is complete
  • Game controls: handle behavior if any of the pads are touched (moving the cursor up/down, drawing lines, clicking the gravity button)
  • Menu controls: draw the menu and show the currently selected item. The menu consists of Reset, Controls, and Levels 1-10
  • Control screen: shows what the behavior of each pad is
  • Sugar behavior: move the sugar based on current position and velocity, handles collisions with lines that are drawn, recycles sugar particles that leave the screen or are stuck for too long
  • Drawing: draws a line between two points, since the cursor position is sample on an interval
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// DISPLAY ============================================================

// from test_display example
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define SCREEN_ADDRESS 0x3C

#define SDA_PIN 6
#define SCL_PIN 7

Adafruit_SSD1306 display(
  SCREEN_WIDTH,
  SCREEN_HEIGHT,
  &Wire,
  -1
);

// Screen is rotated vertically.
#define GAME_W 64
#define GAME_H 128


// ============================================================
// TOUCH PADS
// ============================================================
//
// Q0 -> GP3  -> menu
// Q1 -> GP4  -> draw toggle / select / gravity button
//
// UPDATED DIRECTION MAPPING:
//
// Q2 -> GP2  -> RIGHT
// Q3 -> GP27 -> DOWN
// Q4 -> GP1  -> UP
// Q5 -> GP26 -> LEFT
//

#define N_TOUCH 6
#define THRESHOLD 6

int touch_pins[N_TOUCH] = {
  3,   // Q0
  4,   // Q1
  2,   // Q2
  27,  // Q3
  1,   // Q4
  26   // Q5
};
int touch_values[N_TOUCH] = {0, 0, 0, 0, 0, 0};

bool pin_touched_now[N_TOUCH] = {false, false, false, false, false, false};
bool pin_touched_past[N_TOUCH] = {false, false, false, false, false, false};


// ============================================================
// DRAWING
// ============================================================

bool drawingMask[GAME_W][GAME_H];

int cursorX = GAME_W / 2;
int cursorY = 45;

bool drawingEnabled = false;

unsigned long lastMove = 0;

const unsigned long MOVE_INTERVAL = 40; // controls how quickly the cursor moves when you hold one of the directional touch buttons


// ============================================================
// SUGAR
// ============================================================

#define MAX_SUGAR 100

// struct to keep track of one sugar
struct Sugar {
  float x;
  float y;

  float vx;
  float vy;

  bool active;

  // used to recycle particles that get stuck
  unsigned long lastMovedTime;
};

Sugar sugar[MAX_SUGAR];

unsigned long lastSpawn = 0;

const unsigned long SPAWN_INTERVAL = 75;

const float GRAVITY = 0.075;
const float MAX_FALL_SPEED = 1.35;

// recycle a particle if it has not moved for this long
const unsigned long STUCK_TIMEOUT = 2000;

// +1 = gravity down
// -1 = gravity up
int gravityDirection = 1;


// SUGAR SOURCE ============================================================
// where the sugar falls from
int sugarSourceX = GAME_W / 2;
int sugarSourceY = 13;

// CUPS ============================================================
#define MAX_CUPS 4

struct Cup { // keep track of each cup
  int x;
  int y;

  int width;
  int height;

  int amount;
  int target;

  bool upsideDown;
};

Cup cups[MAX_CUPS];
int numCups = 0;

// GRAVITY BUTTON ============================================================
bool hasGravityButton = false;
// init position and size for gravity button
const int GRAV_BUTTON_X = 49;
const int GRAV_BUTTON_Y = 50;
const int GRAV_BUTTON_W = 12;
const int GRAV_BUTTON_H = 18;


// LEVEL / MENU ============================================================
int level = 1; // starting menu
// menu state
bool menuOpen = false;
int menuIndex = 0;
bool controlsOpen = false;

// Reset + Controls + Levels 1-10
const int MENU_ITEMS = 12;

// TOUCH READING ============================================================
void update_touch() { // from quentin's example
  int t;
  int t_max = 200;
  int p;

  for (int i = 0; i < N_TOUCH; i++) {

    p = touch_pins[i];

    // set to low
    pinMode(p, OUTPUT);
    digitalWriteFast(p, LOW);

    // settle
    delayMicroseconds(25);

    // enable pull-up
    pinMode(p, INPUT_PULLUP);

    // measure time to rise
    t = 0;
    while (!digitalReadFast(p) && t < t_max) {
      t++;
    }
    touch_values[i] = t;

    // update state
    pin_touched_past[i] = pin_touched_now[i];
    pin_touched_now[i] = touch_values[i] > THRESHOLD;
  }
}

// HELPERS ============================================================
bool justTouched(int index) { // returns if pin just started to be touched
  return (pin_touched_now[index] && !pin_touched_past[index]
  );
}

// SETUP ============================================================
// basically from the test_display_RP2040 example 
// https://forge.cba.mit.edu/quentin.bolsee/qpad-xiao/src/branch/main/code/Arduino/test_display_RP2040
void setup() {
  Serial.begin(115200);
  delay(100);

  // Tell RP2040 which pins the display uses
  Wire.setSDA(SDA_PIN);
  Wire.setSCL(SCL_PIN);
  Wire.begin();


  // Initialize OLED
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println("SSD1306 initialization failed");
    while (true);
  }

  Serial.println("SSD1306 initialized");

  display.setRotation(1); // vertical instead of horizontal
  display.clearDisplay();
  display.display();

  randomSeed(micros()); // diff seed for random sugar behavior

  showSplashScreen(); // starting screen for 2 sec

  startLevel(1);
}


// MAIN LOOP ============================================================
void loop() {
  // update the touch sensors
  update_touch();

  // CONTROLS SCREEN
  if (controlsOpen) {
    handleControlsScreen();
    drawControlsScreen();
    delay(16);
    return;
  }

  // MENU
  if (menuOpen) { // menu previously open
    handleMenuControls();
    drawMenu();
    delay(16);
    return;
  }


  // GAME CONTROLS
  handleGameControls();

  // if Q0 opened the menu
  if (menuOpen) {
    drawMenu();
    delay(16);
    return;
  }

  // GAME
  spawnSugar();
  updateSugar();
  drawGame();

  if (levelComplete()) {
    advanceLevel();
  }

  delay(16);
}


// SPLASH SCREEN ============================================================
// shows on start 

void showSplashScreen() {

  // small animation lasting 2 sec
  for (int frame = 0; frame < 40; frame++) {

    display.clearDisplay();
    display.setTextColor(SSD1306_WHITE);

    // "(almost)"
    display.setTextSize(1);
    display.setCursor(8, 28);
    display.print("(almost)");

    // sugar
    display.setTextSize(2);
    display.setCursor(2, 45);
    display.print("sugar");

    // comma
    display.drawPixel(62, 57, SSD1306_WHITE);
    display.drawPixel(61, 58, SSD1306_WHITE);

    // sugar
    display.setTextSize(2);
    display.setCursor(2, 68);
    display.print("sugar");

    // Falling sugar dots
    for (int i = 0; i < 10; i++) {
      int x = (i * 17 + 7) % GAME_W; //*17 is for spacing, +7 shifts the particle
      int y = (frame * 2 + i * 13) % GAME_H; // frame * 2 moves particles down
      display.drawPixel(x, y, SSD1306_WHITE);
    }

    display.display();
    delay(50);
  }
}


// GAME CONTROLS ============================================================
// handles pad touching behavior
void handleGameControls() {
  // Q0 = OPEN MENU
  if (justTouched(0)) {
    menuOpen = true;
    menuIndex = 0;
    drawingEnabled = false;
    return;
  }

  // Q1 = DRAW TOGGLE OR GRAVITY BUTTON
  if (justTouched(1)) {
    // if cursor is on gravity button,
    // activate that instead of drawing
    if (hasGravityButton &&cursorOverGravityButton()) {
      reverseGravity();
    } else {
      drawingEnabled = !drawingEnabled;
      if (drawingEnabled) {
        setDrawPixel(cursorX, cursorY);
      }
    }
  }

  // CURSOR MOVEMENT
  if (millis() - lastMove < MOVE_INTERVAL) { // dont move if 40 millisec hasnt passed yet
    return;
  }

  lastMove = millis();

  int oldX = cursorX;
  int oldY = cursorY;

  // Q4 = UP
  if (pin_touched_now[4]) {
    cursorY--;
  }

  // Q3 = DOWN
  if (pin_touched_now[3]) {
    cursorY++;
  }

  // Q5 = LEFT
  if (pin_touched_now[5]) {
    cursorX--;
  }

  // Q2 = RIGHT
  if (pin_touched_now[2]) {
    cursorX++;
  }

  cursorX = constrain(cursorX, 1, GAME_W - 2); // dont let cursor leave borders
  cursorY = constrain(cursorY, 13, GAME_H - 2);

  // DRAW LINE
  if (drawingEnabled && (oldX != cursorX || oldY != cursorY)
  ) {
    drawIntoMask(oldX, oldY, cursorX, cursorY);
  }
}


// MENU CONTROLS ============================================================
// Handles touch input while the menu is open
void handleMenuControls() {
  // Q0 = close menu
  if (justTouched(0)) {
    menuOpen = false;
    return;
  }

  // Q4 = move selection up
  if (justTouched(4)) {
    menuIndex--;
    if (menuIndex < 0) { // wrap around
      menuIndex = MENU_ITEMS - 1;
    }
  }

  // Q3 = move selection down
  if (justTouched(3)) {
    menuIndex++;
    if (menuIndex >= MENU_ITEMS) {
      menuIndex = 0;
    }
  }

  // Q1 = select
  if (justTouched(1)) {
    if (menuIndex == 0) {
      // reset current level
      startLevel(level);
      menuOpen = false;
    } else if (menuIndex == 1) {
      // Open controls screen
      controlsOpen = true;
      menuOpen = false;
    } else {
      // select level
      int selectedLevel = menuIndex - 1;
      startLevel(selectedLevel);
      menuOpen = false;
    }
  }
}


// DRAW MENU ============================================================
// Draws the menu and highlights the currently selected item
void drawMenu() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);

  // Title
  display.setCursor(18, 1);
  display.print("MENU");
  display.drawLine(0, 10, GAME_W - 1, 10, SSD1306_WHITE);

  const int VISIBLE_ITEMS = 10;
  // Keep the selected item near the middle of the visible menu.
  int firstItem = menuIndex - 4;

  if (firstItem < 0) {
    firstItem = 0;
  }

  if (firstItem > MENU_ITEMS - VISIBLE_ITEMS) {
    firstItem = MENU_ITEMS - VISIBLE_ITEMS;
  }

  // Draw visible menu items
  for (int row = 0; row < VISIBLE_ITEMS; row++) {
    int item = firstItem + row;

    if (item >= MENU_ITEMS) {
      break;
    }

    int y = 14 + row * 11;

    // Selection arrow
    if (item == menuIndex) {
      display.setCursor(0, y);
      display.print(">");
    }

    display.setCursor(8, y);

    if (item == 0) {
      display.print("Reset");
    } else if (item == 1) {
      display.print("Controls");
    } else {
      display.print("Level ");
      display.print(item - 1);
    }
  }

  display.display();
}


// Handles touch input while the controls screen is open
void handleControlsScreen() {
  if (justTouched(0)) {
    controlsOpen = false;
    menuOpen = true;
  }
}

// Draws the controls screen with the game's touch-button mappings
void drawControlsScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);

  // Title
  display.setCursor(8, 1);
  display.print("CONTROLS");
  display.drawLine(0, 10, GAME_W - 1, 10, SSD1306_WHITE);

  // Directions
  display.setCursor(5, 17);
  display.print("Q4  UP");

  display.setCursor(5, 29);
  display.print("Q3  DOWN");

  display.setCursor(5, 41);
  display.print("Q5  LEFT");

  display.setCursor(5, 53);
  display.print("Q2  RIGHT");

  // Actions
  display.setCursor(5, 69);
  display.print("Q1 DRAW");

  display.setCursor(5, 81);
  display.print("   SELECT");

  display.setCursor(5, 97);
  display.print("Q0 MENU");

  // Back instruction
  display.drawLine(0, 110, GAME_W - 1, 110, SSD1306_WHITE);

  display.setCursor(5, 116);
  display.print("Q0 = BACK");

  display.display();
}

// LEVEL SETUP ============================================================

// Initializes a level and resets all level-specific game state
void startLevel(int newLevel) {
  level = newLevel;

  // lears existing drawings and sugar particles
  clearDrawing();
  clearSugar();

  // resets gravity and cursor state
  drawingEnabled = false;
  gravityDirection = 1;
  hasGravityButton = false;
  numCups = 0;

  cursorX = GAME_W / 2;
  cursorY = 45;

  lastSpawn = millis();

  // Level 1: sugar from center, one cup slightly left
  if (level == 1) {
    sugarSourceX = 32;
    sugarSourceY = 14;

    addCup(8, 107, 18, 15, 20, false);
  }

  // Level 2: two normal cups, one upper-left and one lower-right
  else if (level == 2) {
    sugarSourceX = 32;
    sugarSourceY = 14;

    addCup(3, 72, 18, 15, 12, false);
    addCup(41, 108, 18, 15, 12, false);
  }

  // Level 3: one upside-down cup with gravity reversal
  else if (level == 3) {
    sugarSourceX = 32;
    sugarSourceY = 63;
    hasGravityButton = true;

    addCup(4, 15, 18, 15, 18, true);
  }

  // Level 4: one normal and one upside-down cup
  else if (level == 4) {
    sugarSourceX = 32;
    sugarSourceY = 63;
    hasGravityButton = true;

    addCup(4, 15, 18, 15, 12, true);
    addCup(40, 108, 18, 15, 12, false);
  }

  // Level 5: three normal cups
  else if (level == 5) {
    sugarSourceX = 15;
    sugarSourceY = 14;

    addCup(39, 61, 18, 15, 10, false);
    addCup(3, 86, 18, 15, 10, false);
    addCup(39, 108, 18, 15, 10, false);
  }

  // Level 6: two upside-down cups
  else if (level == 6) {
    sugarSourceX = 32;
    sugarSourceY = 65;
    hasGravityButton = true;

    addCup(3, 15, 18, 15, 12, true);
    addCup(41, 30, 18, 15, 12, true);
  }

  // Level 7: one upside-down cup and two normal cups
  else if (level == 7) {
    sugarSourceX = 16;
    sugarSourceY = 61;
    hasGravityButton = true;

    addCup(39, 15, 18, 15, 10, true);
    addCup(3, 91, 18, 15, 10, false);
    addCup(39, 108, 18, 15, 10, false);
  }

  // Level 8: two upside-down cups and one normal cup
  else if (level == 8) {
    sugarSourceX = 48;
    sugarSourceY = 65;
    hasGravityButton = true;

    addCup(3, 15, 18, 15, 10, true);
    addCup(39, 34, 18, 15, 10, true);
    addCup(5, 108, 18, 15, 10, false);
  }

  // Level 9: two upside-down cups and two normal cups
  else if (level == 9) {
    sugarSourceX = 32;
    sugarSourceY = 64;
    hasGravityButton = true;

    addCup(2, 15, 17, 14, 8, true);
    addCup(43, 15, 17, 14, 8, true);
    addCup(2, 109, 17, 14, 8, false);
    addCup(43, 109, 17, 14, 8, false);
  }

  // Level 10: four staggered cups
  else {
    sugarSourceX = 32;
    sugarSourceY = 63;
    hasGravityButton = true;

    addCup(3, 15, 17, 14, 10, true);
    addCup(42, 32, 17, 14, 10, true);
    addCup(3, 82, 17, 14, 10, false);
    addCup(42, 109, 17, 14, 10, false);
  }
}

// Adds a cup to the current level, target is the number of sugar particles needed to fill it
void addCup(int x, int y, int w, int h, int target, bool upsideDown) {
  if (numCups >= MAX_CUPS) {
    return;
  }

  cups[numCups].x = x;
  cups[numCups].y = y;
  cups[numCups].width = w;
  cups[numCups].height = h;
  cups[numCups].amount = 0;
  cups[numCups].target = target;
  cups[numCups].upsideDown = upsideDown;

  numCups++;
}

// NEXT LEVEL ============================================================

// what happens when user clears a level
void advanceLevel() {
  display.clearDisplay();

  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);

  // victory message and move onto next level
  if (level < 10) {
    display.setCursor(12, 52);
    display.print("LEVEL");
    display.setCursor(10, 65);
    display.print("CLEAR!");
    display.display();
    delay(1000);
    startLevel(level + 1);
  }
  else { // finish entire game
    display.setCursor(9, 48);
    display.print("YOU");
    display.setCursor(8, 61);
    display.print("WIN!");

    display.display();
    delay(2000);
    startLevel(1);
  }
}

// LEVEL COMPLETE ============================================================

// return true if level is finished
bool levelComplete() {
  for (int i = 0; i < numCups; i++) {
    if (cups[i].amount < cups[i].target) {
      return false;
    }
  }
  return true;
}

// TOTAL PROGRESS ============================================================
// total amount of sugar currently in all cups
int totalCupAmount() {
  int total = 0;
  for (int i = 0; i < numCups; i++) {
    total += cups[i].amount;
  }
  return total;
}

// total amount needed to fill all of the cups
int totalCupTarget() {
  int total = 0;
  for (int i = 0; i < numCups; i++) {
    total += cups[i].target;
  }
  return total;
}

// GRAVITY ============================================================
void reverseGravity() {
  gravityDirection *= -1;
  // Immediately reverse particles already moving.
  for (int i = 0; i < MAX_SUGAR; i++) {
    if (!sugar[i].active) {
      continue;
    }

    sugar[i].vy *= -1;
    sugar[i].lastMovedTime =  millis(); // give particle fresh stuck timer
  }
}

// if cursor is hovering over the gravity button
bool cursorOverGravityButton() {
  return (
    cursorX >= GRAV_BUTTON_X && cursorX <= GRAV_BUTTON_X + GRAV_BUTTON_W &&
    cursorY >= GRAV_BUTTON_Y && cursorY <= GRAV_BUTTON_Y + GRAV_BUTTON_H);
}

// CLEAR DRAWING ============================================================
void clearDrawing() {
  for (int x = 0; x < GAME_W; x++) {
    for (int y = 0; y < GAME_H; y++) {
      drawingMask[x][y] = false;
    }
  }
}

// DRAW MASK PIXEL ============================================================
// Adds a pixel to the player's drawing mask
void setDrawPixel(int x, int y) {
  // dont draw outside the game area
  if (x < 0 || x >= GAME_W || y < 0 || y >= GAME_H) {
    return;
  }

  // dont draw over the top status area
  if (y < 12) {
    return;
  }

  // dont draw over the gravity button
  if (hasGravityButton &&
      x >= GRAV_BUTTON_X &&
      x <= GRAV_BUTTON_X + GRAV_BUTTON_W &&
      y >= GRAV_BUTTON_Y &&
      y <= GRAV_BUTTON_Y + GRAV_BUTTON_H) {
    return;
  }

  drawingMask[x][y] = true;
}

// BRESENHAM LINE DRAWING ============================================================
// darws a line from (x0, y0) to (x1, y1) into the drawing mask
// Uses Bresenham's line algorithm to fill in every pixel between
// the two points, preventing gaps when the cursor moves
void drawIntoMask(int x0, int y0, int x1, int y1) {
  int dx = abs(x1 - x0);
  int sx = x0 < x1 ? 1 : -1;

  int dy = -abs(y1 - y0);
  int sy = y0 < y1 ? 1 : -1;

  int error = dx + dy;

  while (true) {
    setDrawPixel(x0, y0);

    if (x0 == x1 && y0 == y1) {
      break;
    }

    int e2 = 2 * error;

    if (e2 >= dy) {
      error += dy;
      x0 += sx;
    }

    if (e2 <= dx) {
      error += dx;
      y0 += sy;
    }
  }
}


// SUGAR ============================================================
void clearSugar() {
  for (int i = 0; i < MAX_SUGAR; i++) {
    sugar[i].active = false;
  }
}

// Spawns a new sugar particle at a fixed time interval
void spawnSugar() {
  if (millis() - lastSpawn < SPAWN_INTERVAL) {
    return;
  }

  lastSpawn = millis();

  for (int i = 0; i < MAX_SUGAR; i++) {
    if (!sugar[i].active) {
      sugar[i].active = true;

      // init sugar source with small random horizontal and vertical velocity
      sugar[i].x = sugarSourceX + random(-1, 2);
      sugar[i].y = sugarSourceY;

      sugar[i].vx = random(-12, 13) / 100.0;
      sugar[i].vy = gravityDirection * (random(2, 10) / 100.0);

      sugar[i].lastMovedTime = millis();

      return;
    }
  }
}

// Updates the position and state of every active sugar particle
// Applies gravity, handles collisions with cups and drawn lines,
// and recycles particles that become stuck or leave the screen
void updateSugar() {
  for (int i = 0; i < MAX_SUGAR; i++) {
    if (!sugar[i].active) {
      continue;
    }

    Sugar &p = sugar[i];

    float oldX = p.x;
    float oldY = p.y;

    // Apply gravity and limit vertical speed
    p.vy += gravityDirection * GRAVITY; // accerlate

    if (p.vy > MAX_FALL_SPEED) {
      p.vy = MAX_FALL_SPEED;
    }

    if (p.vy < -MAX_FALL_SPEED) {
      p.vy = -MAX_FALL_SPEED;
    }

    float nextX = p.x + p.vx;
    float nextY = p.y + p.vy;

    // capture particles that enter a cup
    if (checkCupCapture(p.x, p.y, nextX, nextY, p.vy)) {
      p.active = false;
      continue;
    }

    // handle collisions with drawn lines
    if (hitsDrawing(nextX, nextY)) {
      float checkY = p.y + gravityDirection;

      // make sugar slide left or right when it hits one 
      // of your drawn lines, rather than just stopping
      bool leftFree = !hitsDrawing(p.x - 1, checkY);
      bool rightFree = !hitsDrawing(p.x + 1, checkY);

      if (leftFree && rightFree) {
        p.vx = (random(2) == 0) ? -0.65 : 0.65;
        p.vy = gravityDirection * 0.12;

      } else if (leftFree) {
        p.vx = -0.65;
        p.vy = gravityDirection * 0.12;

      } else if (rightFree) {
        p.vx = 0.65;
        p.vy = gravityDirection * 0.12;

      } else {
        // Stop particles that are trapped
        p.vy = 0;
        p.vx *= 0.5;
      }

      p.x += p.vx;

    } else { // freefall, move normally
      p.x = nextX;
      p.y = nextY;
      p.vx *= 0.995;
    }

    // reset the stuck timer whenever the particle moves
    float movement = fabsf(p.x - oldX) + fabsf(p.y - oldY);
    if (movement > 0.05) {
      p.lastMovedTime = millis();
    }

    // recycle particles that are stuck
    if (millis() - p.lastMovedTime > STUCK_TIMEOUT) {
      p.active = false;
      continue;
    }

    // recylce particles that leave the screen
    if (p.x < 0 || p.x >= GAME_W || p.y < 0 || p.y >= GAME_H) {
      p.active = false;
    }
  }
}

// Checks whether a sugar particle overlaps the player's drawing
bool hitsDrawing(float fx, float fy) {
  int x = round(fx);
  int y = round(fy);

  if (x < 0 || x >= GAME_W || y < 0 || y >= GAME_H) {
    return false;
  }

  // Check the 3x3 area surrounding the particle to stop overalapping thin lines
  for (int dx = -1; dx <= 1; dx++) {
    for (int dy = -1; dy <= 1; dy++) {
      int xx = x + dx;
      int yy = y + dy;

      if (xx >= 0 && xx < GAME_W && yy >= 0 && yy < GAME_H) {
        if (drawingMask[xx][yy]) {
          return true;
        }
      }
    }
  }

  return false;
}


// Checks whether a sugar particle enters one of the cups
bool checkCupCapture(float oldX, float oldY, float newX, float newY, float velocityY) {
  for (int i = 0; i < numCups; i++) {
    Cup &c = cups[i];

    // ignore cups that are already full
    if (c.amount >= c.target) {
      continue;
    }

    // check whether the particle is horizontally inside the cup opening
    bool insideX = newX > c.x + 2 && newX < c.x + c.width - 2;

    if (!insideX) {
      continue;
    }

    if (!c.upsideDown) {
      // nromal cup: particle must cross the opening while moving down
      float mouthY = c.y;

      if (velocityY > 0 && oldY < mouthY && newY >= mouthY) {
        c.amount++;
        return true;
      }

    } else {
      // upside-down cup: particle must cross the opening while moving up
      float mouthY = c.y + c.height;

      if (velocityY < 0 && oldY > mouthY && newY <= mouthY) {
        c.amount++;
        return true;
      }
    }
  }
  return false;
}

// DRAW GAME ============================================================
// Draws the current game state to the OLED display.
void drawGame() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);

  // Level and total progress
  display.setCursor(1, 0);
  display.print("L");
  display.print(level);

  display.setCursor(25, 0);
  display.print(totalCupAmount());
  display.print("/");
  display.print(totalCupTarget());

  // Sugar source
  display.drawPixel(sugarSourceX, sugarSourceY, SSD1306_WHITE);
  display.drawPixel(sugarSourceX - 1, sugarSourceY, SSD1306_WHITE);
  display.drawPixel(sugarSourceX + 1, sugarSourceY, SSD1306_WHITE);

  // Player-drawn lines
  for (int x = 0; x < GAME_W; x++) {
    for (int y = 12; y < GAME_H; y++) {
      if (drawingMask[x][y]) {
        display.drawPixel(x, y, SSD1306_WHITE);
      }
    }
  }

  // Gravity button
  if (hasGravityButton) {
    drawGravityButton();
  }

  // Cups
  for (int i = 0; i < numCups; i++) {
    drawCup(cups[i]);
  }

  // Sugar particles
  for (int i = 0; i < MAX_SUGAR; i++) {
    if (!sugar[i].active) {
      continue;
    }

    int x = round(sugar[i].x);
    int y = round(sugar[i].y);

    if (x >= 0 && x < GAME_W && y >= 0 && y < GAME_H) {
      display.drawPixel(x, y, SSD1306_WHITE);
    }
  }

  // Drawing cursor
  display.drawCircle(cursorX, cursorY, 2, SSD1306_WHITE);

  // Center dot indicates that drawing is enabled.
  if (drawingEnabled) {
    display.drawPixel(cursorX, cursorY, SSD1306_WHITE);
  }

  display.display();
}


// Draws a cup and visually represents how full it is.
void drawCup(Cup &c) {
  int x = c.x;
  int y = c.y;
  int w = c.width;
  int h = c.height;

  if (!c.upsideDown) {
    // Normal cup: two sides and a bottom
    display.drawLine(x, y, x + 2, y + h, SSD1306_WHITE);
    display.drawLine(x + w, y, x + w - 2, y + h, SSD1306_WHITE);
    display.drawLine(x + 2, y + h, x + w - 2, y + h, SSD1306_WHITE);

  } else {
    // Upside-down cup: two sides and a top
    display.drawLine(x + 2, y, x + w - 2, y, SSD1306_WHITE);
    display.drawLine(x + 2, y, x, y + h, SSD1306_WHITE);
    display.drawLine(x + w - 2, y, x + w, y + h, SSD1306_WHITE);
  }

  // Calculate how much of the cup should appear filled
  float percentage = (float)c.amount / (float)c.target;
  percentage = constrain(percentage, 0.0, 1.0);
  int fillHeight = percentage * (h - 3);

  if (fillHeight <= 0) {
    return;
  }

  if (!c.upsideDown) {
    // Normal cup fills from the bottom upward
    display.fillRect(
      x + 3,
      y + h - fillHeight,
      w - 5,
      fillHeight,
      SSD1306_WHITE
    );

  } else {
    // Upside-down cup fills from the top downward.
    display.fillRect(
      x + 3,
      y + 2,
      w - 5,
      fillHeight,
      SSD1306_WHITE
    );
  }
}

void drawGravityButton() {
  display.drawRect(
    GRAV_BUTTON_X,
    GRAV_BUTTON_Y,
    GRAV_BUTTON_W,
    GRAV_BUTTON_H,
    SSD1306_WHITE
  );

  int cx =
    GRAV_BUTTON_X +
    GRAV_BUTTON_W / 2;


  // If gravity currently points DOWN, button shows UP
  if (gravityDirection > 0) {
    display.drawLine(
      cx,
      GRAV_BUTTON_Y + 14,
      cx,
      GRAV_BUTTON_Y + 5,
      SSD1306_WHITE
    );

    display.fillTriangle(
      cx,
      GRAV_BUTTON_Y + 3,
      cx - 3,
      GRAV_BUTTON_Y + 7,
      cx + 3,
      GRAV_BUTTON_Y + 7,
      SSD1306_WHITE
    );
  }
  // Gravity currently DOWN, button shows UP
  else {
    display.drawLine(
      cx,
      GRAV_BUTTON_Y + 4,
      cx,
      GRAV_BUTTON_Y + 13,
      SSD1306_WHITE
    );

    display.fillTriangle(
      cx,
      GRAV_BUTTON_Y + 15,
      cx - 3,
      GRAV_BUTTON_Y + 11,
      cx + 3,
      GRAV_BUTTON_Y + 11,
      SSD1306_WHITE
    );
  }
}

Group assignment

This week’s group assignment was to demonstrate and compare the toolchains and development workflows for available embedded architectures. Our group assignment page is here. My contribution this week was organizing a weekly group assignment times so we can keep our schedules free (yay!). During our meeting, I helped think of different toolchains/workflows we could test with and tested the LED echo and LED count examples for Micropython using Thonny. I also helped with the Micropython/VSCode workflow.