Final Project: Inside Out Runner Game

For this final project, I built an interactive game that represents the mental world of a child’s mind—heavily inspired by Inside Out. The digital part of the game was made using p5.js, and it connects to three physical Arduino setups: two for the moving characters (Joy and Sadness) and one for the glowing memory balls.

You play by dodging obstacles and collecting star-shaped powerups. There are two types of obstacles: the memory bridge (which the player slides under) and the memory ball cluster (which you jump over). As the player moves through the level, their real-life emotions move along a race track: Joy advances when the player collects powerups, and Sadness moves forward every time an obstacle is hit.

If Sadness reaches the end first (if user hit 3 obstacles), a memory ball glows blue. If Joy wins (3 powerups), it glows yellow.


Hardware + Fabrication:

  • I designed the track pattern in Illustrator and laser-cut it .

  • I downloaded STL files of Joy and Sadness from Printables, 3D printed them, and painted them with acrylic paint. Then i laser cut a stand and glued them to it so that they were on top of the arduino boards that were connected to the wheels.

  • I used SolidWorks to model the memory ball, 3D printed it, and added LEDs connected to breadboards inside then connected the 2 breadboards to a single arduino.

  •  


Code + Arduino Integration:

At first, I wanted to send letters (‘O’, ‘P’, etc.) from p5 to Arduino to trigger events, but it just wasn’t reliable. After about 8 hours of trial and error, I figured out that using numbers instead of characters made the communication consistent.

Another issue was the Arduinos themselves. Because the boards are inside the moving characters, they were getting damaged by all the motion and collisions. They’d randomly stop working even though the wiring was fine. I had to replace the connections multiple times, and one of the boards is still a little unstable.


Arduino Code – Joy & Sadness Movement (Boards 1 & 2):

const int STBY = 6;

// Motor A
const int AIN1 = 4;
const int AIN2 = 5;
const int PWMA = 3;

// Motor B
const int BIN1 = 7;
const int BIN2 = 8;
const int PWMB = 9;

void setup() {
  Serial.begin(9600);

  pinMode(STBY, OUTPUT);
  pinMode(AIN1, OUTPUT);
  pinMode(AIN2, OUTPUT);
  pinMode(PWMA, OUTPUT);
  pinMode(BIN1, OUTPUT);
  pinMode(BIN2, OUTPUT);
  pinMode(PWMB, OUTPUT);

  digitalWrite(STBY, HIGH); // Enable motor driver
}

void loop() {
  if (Serial.available()) {
    char command = Serial.read();

    if (command == 0 || command == 1) {
      moveForward(200); // Set speed here (0–255)
      delay(1000);       // Adjust this value to travel the desired cm (experiment to tune)
      stopMotors();
    }
  }
}

void moveForward(int speed) {
  // Motor A forward
  digitalWrite(AIN1, HIGH);
  digitalWrite(AIN2, LOW);
  analogWrite(PWMA, speed);

  // Motor B forward
  digitalWrite(BIN1, HIGH);
  digitalWrite(BIN2, LOW);
  analogWrite(PWMB, speed);
}

void stopMotors() {
  // Stop Motor A
  analogWrite(PWMA, 0);
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, LOW);

  // Stop Motor B
  analogWrite(PWMB, 0);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, LOW);
}

 


Arduino Code – Memory Ball LEDs (Board 3):

// Pins for Motor Driver (L298N or similar)
#define MOTOR1_PIN1 2
#define MOTOR1_PIN2 3
#define MOTOR2_PIN1 4
#define MOTOR2_PIN2 5

void setup() {
  Serial.begin(9600);
  pinMode(MOTOR1_PIN1, OUTPUT);
  pinMode(MOTOR1_PIN2, OUTPUT);
  pinMode(MOTOR2_PIN1, OUTPUT);
  pinMode(MOTOR2_PIN2, OUTPUT);
}

void loop() {
  if (Serial.available()) {
    char cmd = Serial.read();
    if (cmd == 'O') { // 'O' for Obstacle
      // Move both motors forward for 1 second
      digitalWrite(MOTOR1_PIN1, HIGH);
      digitalWrite(MOTOR1_PIN2, LOW);
      digitalWrite(MOTOR2_PIN1, HIGH);
      digitalWrite(MOTOR2_PIN2, LOW);
      delay(1000);
      stopMotors();
    }
  }
}

void stopMotors() {
  digitalWrite(MOTOR1_PIN1, LOW);
  digitalWrite(MOTOR1_PIN2, LOW);
  digitalWrite(MOTOR2_PIN1, LOW);
  digitalWrite(MOTOR2_PIN2, LOW);
}


The p5.js Sketch:

The sketch handles the game logic—collision detection, button controls, score tracking, and serial communication with  Arduinos.

  • Character controller with jump/slide mechanics

  • Obstacle spawning at set intervals

  • Collision detection that updates the win count for Joy or Sadness

  • Serial output that sends a number to each board depending on the in-game event


What I’m Most Proud Of:

Definitely the connection between digital and physical. Seeing Joy or Sadness physically move when something happens in the game is so rewarding. It brings the internal emotional world of the game into the real world.

Also, figuring out how to manage serial communication across 3 Arduinos (despite many boards dying on me) felt like a huge accomplishment.


If I Had More Time:

  • Improve the Arduino casing so that the wires don’t come loose so easily

  • Add a small screen showing who’s winning in real-time

P5 Code: 

let port1, port2;
let connectBtn1, connectBtn2;
let player;
let obstacles = [];
let laneWidth;
let lanes = [0, 1, 2];
let score = 0;
let gameSpeed = 5;
let obstacleSpawnCounter = 0;
let bg1, bg2;
let gameState = "start";
let startButton;
let powerUps = [];
let powerUpSpawnCounter = 0;
let gameMusic;
let obstacleHits = 0;
let powerUpHits = 0;
const MAX_HITS = 3;

function preload() {
  bg2 = loadImage("background.png");
  bg1 = loadImage("headquarters.png");
  gameMusic = loadSound("Song.mp3");
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  laneWidth = width / 3;
  setupGame();

  port1 = createSerial();
  port2 = createSerial();

  startButton = createButton("Start Game");
  startButton.position(width / 2 - 50, height / 2 + 40);
  startButton.size(100, 40);
  startButton.mousePressed(() => {
    gameState = "playing";
    startButton.hide();
  });

  connectBtn1 = createButton("Connect Arduino 1 (Obstacle)");
  connectBtn1.position(50, 50);
  connectBtn1.mousePressed(() => port1.open(9600));

  connectBtn2 = createButton("Connect Arduino 2 (Powerup)");
  connectBtn2.position(50, 100);
  connectBtn2.mousePressed(() => port2.open(9600));
}

function setupGame() {
  player = new Player();
  obstacles = [];
  score = 0;
  gameSpeed = 5;
  obstacleSpawnCounter = 0;
  powerUps = [];
  powerUpSpawnCounter = 0;
  obstacleHits = 0;
  powerUpHits = 0;

  if (gameState === "start" && startButton) {
    startButton.show();
  }
}

function draw() {
  background(bg2);

  if (gameState === "start") {
    drawStartScreen();
  } else if (gameState === "playing") {
    if (!gameMusic.isPlaying()) {
      gameMusic.loop();
    }
    runGame();
  } else if (gameState === "gameover") {
    if (gameMusic.isPlaying()) {
      gameMusic.stop();
    }
    drawGameOverScreen();
  }
}

function drawStartScreen() {
  fill(255);
  background(bg1);
  textAlign(CENTER, CENTER);
  textSize(48);
  text("INSIDE OUT RUNNER", width / 2, height / 2 - 120);

  textSize(24);
  text("Instructions:", width / 2, height / 2 - 50);
  textSize(20);
  text("Up Arrow: Jump over Memory Ball Cluster (obstacle)", width / 2, height / 2 - 15);
  text("Down Arrow: Slide under Memory Bridge (obstacle)", width / 2, height / 2 + 5);
  text("Left/Right Arrows: Move between the 3 lanes", width / 2, height / 2 + 25);

  textSize(24);
  text("Objective:", width / 2, height / 2 + 100);
  textSize(20);
  text("Collect 3 stars for Joy to win!", width / 2, height / 2 + 130);
  text("Hit 3 obstacles for Sadness to win!", width / 2, height / 2 + 150);
}

function drawGameOverScreen() {
  background(bg1);
  fill(255, 50, 50);
  textAlign(CENTER, CENTER);
  textSize(48);
  text("Game Over", width / 2, height / 2 - 40);
  textSize(24);
  text("Final Score: " + floor(score / 10), width / 2, height / 2 + 10);
  text("Press R to Restart", width / 2, height / 2 + 50);

  if (obstacleHits >= 3) {
    text("Sadness Wins!", width / 2, height / 2 + 80);
  } else if (powerUpHits >= 3) {
    text("Joy Wins!", width / 2, height / 2 + 80);
  }
}

function runGame() {
  gameSpeed += 0.0005;
  background(bg2);

  player.update();
  player.show();

  obstacleSpawnCounter++;
  if (obstacleSpawnCounter > max(90 - gameSpeed * 5, 40)) {
    obstacles.push(new Obstacle());
    obstacleSpawnCounter = 0;
  }

  for (let i = obstacles.length - 1; i >= 0; i--) {
    if (obstacles[i].offscreen()) {
      obstacles.splice(i, 1);
      continue;
    }
    obstacles[i].move();
    obstacles[i].show();

    if (obstacles[i].hits(player)) {
      obstacleHits++;
      obstacles.splice(i, 1);
      if (port1 && port1.opened()) {
        port1.write(0);
      }
      if (obstacleHits >= MAX_HITS) {
        gameState = "gameover";
      }
    }
  }

  powerUpSpawnCounter++;
  if (powerUpSpawnCounter > 300) {
    powerUps.push(new PowerUp());
    powerUpSpawnCounter = 0;
  }

  for (let i = powerUps.length - 1; i >= 0; i--) {
    if (powerUps[i].offscreen()) {
      powerUps.splice(i, 1);
      continue;
    }
    powerUps[i].move();
    powerUps[i].show();

    if (powerUps[i].hits(player)) {
      powerUpHits++;
      powerUps.splice(i, 1);
      score += 100;
      if (port2 && port2.opened()) {
        port2.write(1);
      }
      if (powerUpHits >= MAX_HITS) {
        gameState = "gameover";
      }
    }
  }

  score += 1;
  fill(255);
  textSize(24);
  textAlign(LEFT);
  text("Score: " + floor(score / 10), 10, 30);

  textAlign(RIGHT);
  text("Obstacles: " + obstacleHits + "/3", width - 10, 30);
  text("Powerups: " + powerUpHits + "/3", width - 10, 60);
  textAlign(LEFT);
}

function keyPressed() {
  if (gameState === "start" && key === " ") {
    gameState = "playing";
  } else if (gameState === "gameover" && (key === "r" || key === "R")) {
    setupGame();
    gameState = "playing";
  }

  if (gameState === "playing") {
    if (keyCode === LEFT_ARROW) {
      player.move(-1);
    } else if (keyCode === RIGHT_ARROW) {
      player.move(1);
    } else if (keyCode === UP_ARROW) {
      player.jump();
    } else if (keyCode === DOWN_ARROW) {
      player.slide();
    }
  }
}

class Player {
  constructor() {
    this.lane = 1;
    this.x = laneWidth * this.lane + laneWidth / 2;
    this.baseY = height - 50;
    this.y = this.baseY;
    this.r = 20;
    this.gravity = 1;
    this.velocity = 0;
    this.isJumping = false;
    this.isSliding = false;
    this.slideTimer = 0;
  }

  show() {
    fill(255);
    noStroke();
    if (this.isSliding) {
      ellipse(this.x, this.y + this.r / 2, this.r * 2, this.r);
    } else {
      ellipse(this.x, this.y, this.r * 2);
    }
  }

  move(dir) {
    this.lane += dir;
    this.lane = constrain(this.lane, 0, 2);
    this.x = laneWidth * this.lane + laneWidth / 2;
  }

  jump() {
    if (!this.isJumping && !this.isSliding) {
      this.velocity = -20;
      this.isJumping = true;
    }
  }

  slide() {
    if (!this.isJumping && !this.isSliding) {
      this.isSliding = true;
      this.slideTimer = 30;
    }
  }

  update() {
    this.y += this.velocity;
    this.velocity += this.gravity;

    if (this.y >= this.baseY) {
      this.y = this.baseY;
      this.velocity = 0;
      this.isJumping = false;
    }

    if (this.isSliding) {
      this.slideTimer--;
      if (this.slideTimer <= 0) {
        this.isSliding = false;
      }
    }
  }
}

function drawEmotionCloud(x, y) {
  const colors = ['#A066FF', '#FF6666', '#FFFF66', '#66FF66', '#66CCFF'];
  for (let i = 0; i < 5; i++) {
    fill(colors[i % colors.length]);
    noStroke();
    ellipse(x + i * 20, y + sin(i) * 8, 30, 30);
  }
}

function drawMemoryBridge(x, y) {
  fill("purple");
  beginShape();
  for (let dx = -60; dx <= 60; dx += 5) {
    let dy = -20 * sin(PI * (dx + 60) / 120);
    vertex(x + dx, y + dy);
  }
  for (let dx = 60; dx >= -60; dx -= 5) {
    let dy = -10 * sin(PI * (dx + 60) / 120);
    vertex(x + dx, y + dy + 20);
  }
  endShape(CLOSE);

  stroke(0);
  strokeWeight(2);
  for (let dx = -50; dx <= 50; dx += 20) {
    let dy = -20 * sin(PI * (dx + 60) / 120);
    line(x + dx, y + dy, x + dx, y + dy - 10);
  }

  noFill();
  strokeWeight(2);
  beginShape();
  for (let dx = -50; dx <= 50; dx += 5) {
    let dy = -20 * sin(PI * (dx + 60) / 120);
    vertex(x + dx, y + dy - 10);
  }
  endShape();
}

// --- Obstacle Class ---
class Obstacle {
  constructor() {
    this.lane = random(lanes);
    this.x = laneWidth * this.lane + laneWidth / 2;
    this.y = 350;
    if (random(1) < 0.5) {
      this.type = "high";
    } else {
      this.type = "low";
    }
    this.color = color("blue");
  }

  move() {
    this.y += gameSpeed;
  }

  show() {
    if (this.type === "low") {
      drawMemoryBridge(this.x, this.y);
    } else {
      drawEmotionCloud(this.x, this.y);
    }
  }

  hits(player) {
    let playerRadius = player.isSliding ? player.r * 0.5 : player.r;
    let px = player.x;
    let py = player.isSliding ? player.y + player.r / 2 : player.y;

    let collisionX = abs(this.x - px) < 60 / 2 + playerRadius;
    let collisionY = abs(this.y - py) < 40 / 2 + playerRadius;

    if (collisionX && collisionY) {
      if (this.type === "high" && !player.isJumping) return true;
      if (this.type === "low" && !player.isSliding) return true;
    }
    return false;
  }

  offscreen() {
    return this.y > height + 40;
  }
}

// --- PowerUp Class ---
class PowerUp {
  constructor() {
    this.lane = random(lanes);
    this.x = laneWidth * this.lane + laneWidth / 2;
    this.y = 350;
    this.size = 30;
    this.color = color(255, 174, 66);
  }

  move() {
    this.y += gameSpeed;
  }

  show() {
    push();
    fill(this.color);
    noStroke();
    translate(this.x, this.y);
    beginShape();
    for (let i = 0; i < 10; i++) {
      let angle = TWO_PI * i / 10;
      let r = i % 2 === 0 ? this.size : this.size / 2;
      let sx = cos(angle) * r;
      let sy = sin(angle) * r;
      vertex(sx, sy);
    }
    endShape(CLOSE);
    pop();
  }

  offscreen() {
    return this.y > height + this.size;
  }

  hits(player) {
    let d = dist(this.x, this.y, player.x, player.y);
    return d < this.size + player.r;
  }
}

 

Final project testing

For this step of the project, I focused on user testing and started making some updates based on how people interacted with the game. The concept is a physical memory-based game where two characters move forward when the player makes a correct choice, but if they hit an obstacle, a glowing memory ball (controlled by another Arduino) lights up.

Right now, I’m still working on the Arduino connection between the boards. Each moving character has its own Arduino, and I’ve written code that lets them move forward when they receive a command — but syncing the movement between the two boards and handling serial communication properly is still a work in progress. I’m also still refining how everything connects with the third Arduino, which controls the obstacle LED reaction in the memory balls.

During testing, I noticed that sometimes one board would move while the other didn’t, or the LED wouldn’t react consistently. So I’ve been going back into the code and checking the timing, delays, and serial input handling. It’s been a bit tricky trying to get all three Arduinos to communicate clearly without any delays or missed signals.

This week, I’m focusing on:

**Finalizing the Arduino code for the two moving boards
**Getting the serial communication between the three Arduinos to work smoothly

The part I’m most proud of so far is how the basic movement is already working and how the obstacle-triggered lights bring the whole thing to life. Once the connection part is sorted out, I think the physical interactions will feel much more natural and immersive.

Week 11

For this assignment, Izza and I worked together to do the 3 tasks.

Task 1: 

For this task, we used a potentiometer to control whether the ellipse was moving to the left or the right on the horizontal axis. We did this by mapping the values of the potentiometer (0-1023) on the horizontal axis. Then, when we turned the potentiometer the value would translate to a position on the horizontal axis that the ball would move to. We had some difficulties with the delay between the arduino and p5js as sometimes we’d have to wait a couple seconds before the code would update in p5. Here is the code for the arduino:

void setup() {

  Serial.begin(9600);

}




// Read potentiometer value (0–1023) and sends to p5js

void loop() {

  int sensorValue = analogRead(A0);

  Serial.println(sensorValue);

  delay(1);

}

 

Task 2:

For this task, we had to do something that controlled the brightness of an LED on the arduino breadboard through p5js. So, we decided to create a dropdown for the user to pick between 1-10 to control the brightness of the LED with 1 being off and 10 being maximum brightness. We then mapped that to the 0-255 range for the brightness of an LED and sent that to the arduino which would control how brightly the LED would light up for a few seconds. On the arduino we simply had one bulb connected to digital pin 9. The arduino code can be seen below:

void setup() {

  Serial.begin(9600);

  pinMode(9, OUTPUT);

}




//gets the serial converted value from p5js

void loop() {

  if (Serial.available() > 0) {

    int brightness = Serial.parseInt(); 

    brightness = constrain(brightness, 0, 255); //make sure the value isn't out of range

    analogWrite(9, brightness);

  }

}

 

Task 3:

In this task, we had to take already existing code and alter it such that every time the ball bounced, one LED light on the arduino lit up, and the wind was controlled by an analog sensor. For controlling our wind, we used a potentiometer once again as we could make it such that values above 512 would move the ball to the east (right) and values below 512 would move the ball towards the west (left). On the arduino, we connected a potentiometer at analog pin A0 and an LED light on digital pin 9. We then used p5js to recieve that serial input from the potentiometer and map it to the wind. Whether it bounced being true or false is also what makes the LED light up. Once again, we did experience a delay between the potentiometer value and the wind in p5. The arduino code can be seen below:

const int potPin = A0;

const int ledPin = 9;

bool ledOn = false;

unsigned long ledTimer = 0;

const int ledDuration = 100;




void setup() {

  Serial.begin(9600);

  pinMode(ledPin, OUTPUT);

}




void loop() {

  // Read potentiometer and send value

  int potValue = analogRead(potPin);

  Serial.println(potValue);




  // If LED was turned on recently, turn it off after some time

  if (ledOn && millis() - ledTimer > ledDuration) {

    digitalWrite(ledPin, LOW);

    ledOn = false;

  }




  // recieve signal on whether the ball bounced from p5.js

  if (Serial.available()) {

    String input = Serial.readStringUntil('\n');

    input.trim();




    if (input == "bounce") {

      digitalWrite(ledPin, HIGH);

      ledOn = true;

      ledTimer = millis();

    }

  }




  delay(10); // Slight delay for stability

}

 

Lastly, here is the link to the video showing the LED light up and the ball being “blown” away by the value sent from the potentiometer:

https://drive.google.com/file/d/140pGv-9DMPd1gCa1xMn_LR3pR_pphx47/view?usp=sharing

Final Project

Concept & Goals

Inspiration: Give a plant a “voice” through digital animation and sound, fostering empathy and care.

The idea was to monitor ambient light and human touch on the plant, translate those signals into a friendly digital avatar’s mood, color-changing lamp, moving leaf, and background music.

    • Goals:

      1. Awareness of plant wellbeing via playful tech.

      2. Interaction through capacitive touch (DIY sensor) and light sensing.

      3. Empathy by giving the plant a way to “talk back.”

Video

Avatar

Setup

Hardware Overview

1. Capacitive Touch Sensor (DIY)

    • Pins: D4 (send) → 1 MΩ resistor → D2 (receive + foil/copper tape).

    • Library: Paul Badger’s CapacitiveSensor. Downloaded for the Arduino code.

    • Assembly:

      1. Connect a 1 MΩ resistor between pin 4 and pin 2.

      2. Attach copper tape to the receiving leg (pin 2) and wrap gently around the plant’s leaves.

      3. In code: CapacitiveSensor capSensor(4, 2);

2. Photoresistor (Ambient Light)

    • Pins: LDR → +5 V; 10 kΩ → GND; junction → A0.

    • Function: Reads 0–1023, mapped to 0–255 to control lamp intensity.

3. Push-Button (Music Control)

    • Pins: Button COM → D7, NO → GND (using INPUT_PULLUP).

4. Mood LEDs

    • Pins:

      • Green LED1: D12 → 330 Ω → LED → GND

      • Red LED2: D13 → 330 Ω → LED → GND

    • Behavior:

      • Red LED on when touch > high threshold (indicates that the plant does not like the touch).

      • Green LED on when touch < low threshold (the plant is calm and likes the touch).

Arduino Code

#include <CapacitiveSensor.h>

// Capacitive sensor: send→ 4, receive→ 2
CapacitiveSensor capSensor(4, 2);

// Mood LEDs
const int ledPin   = 12;
const int ledPin2  = 13;

// Photoresistor
const int photoPin = A0;

// Push-button + button LED
const int buttonPin    = 7;  // COM→7, NO→GND


// Hysteresis thresholds
const long thresholdHigh = 40;
const long thresholdLow  = 20;

// Debounce
const unsigned long debounceDelay = 50;
unsigned long lastDebounceTime    = 0;
int           lastButtonReading   = HIGH;

// State trackers
bool musicOn = false;
bool led1On  = false;

void setup() {
  Serial.begin(9600);
  delay(100);

  // Let p5.js know we start OFF
  Serial.println("MUSIC_OFF");

  // Mood LEDs
  pinMode(ledPin,   OUTPUT);
  pinMode(ledPin2,  OUTPUT);

  // Capacitive sensor raw
  capSensor.set_CS_AutocaL_Millis(0);

  // Button LED off
  pinMode(buttonLedPin, OUTPUT);
  digitalWrite(buttonLedPin, LOW);

  // Push-button with pull-up
  pinMode(buttonPin, INPUT_PULLUP);
}

void loop() {
  // Button toggle (only prints MUSIC_ON / MUSIC_OFF) 
  int reading = digitalRead(buttonPin);
  if (reading != lastButtonReading) {
    lastDebounceTime = millis();
  }
  if (millis() - lastDebounceTime > debounceDelay) {
    static int buttonState = HIGH;
    if (reading != buttonState) {
      buttonState = reading;
      if (buttonState == LOW) {            // you pressed
        musicOn = !musicOn;
        Serial.println(musicOn ? "MUSIC_ON" : "MUSIC_OFF");
        digitalWrite(buttonLedPin, musicOn ? HIGH : LOW);
      }
    }
  }
  lastButtonReading = reading;

  // Capacitive
  long sensorValue = capSensor.capacitiveSensor(30);
  Serial.println(String("TOUCH:") + sensorValue);

  // Mood LED hysteresis 
  if (!led1On && sensorValue > thresholdHigh) {
    led1On = true;
  } else if (led1On && sensorValue < thresholdLow) {
    led1On = false;
  }
  digitalWrite(ledPin,  led1On ? HIGH : LOW);
  digitalWrite(ledPin2, led1On ? LOW  : HIGH);

  // Photoresistor
  int raw       = analogRead(photoPin);
  int mappedVal = map(raw, 0, 1023, 0, 255);
  Serial.println(String("LAMP:") + mappedVal);

  delay(50);
}

Serial messages:

    • MUSIC_ON / MUSIC_OFF (button)

    • TOUCH:<value> (capacitive)

    • LAMP:<0–255> (light)

P5js Code

let port;
const baudrate = 9600;
let connectButton;
let bgMusic;
let interactionStarted = false;
let isTouched = false;
let lampBrightness = 0;


let plankCount = 6;
let cam;
let myFont;
let waveAngle = 0;
let isWaving = false;
let waveStartTime = 0;



function preload() {
  myFont = loadFont('CalligraphyFLF.ttf');
  bgMusic = loadSound('musica.mp3');  // load  MP3
}
  // Works for clicks, touches, and fullscreen events
  const unlockAudio = () => {
    if (!audioUnlocked) {
      getAudioContext().resume().then(() => {
        console.log('AudioContext unlocked');
        audioUnlocked = true;
        if (musicOn && bgMusic.isLoaded()) bgMusic.loop();
      });
    }
  };
  // Mouse/touch unlock
  window.addEventListener('mousedown', unlockAudio);
  window.addEventListener('touchstart', unlockAudio);
  // Also unlock on fullscreen change
  document.addEventListener('fullscreenchange', unlockAudio);

function setup() {
  createCanvas(windowWidth, windowHeight, WEBGL);

  connectButton = createButton("Connect to Arduino");
  connectButton.position(20, 20);
  connectButton.mousePressed(() => {
    port.open(baudrate);
  });

  port = createSerial();
  const used = usedSerialPorts();
  if (used.length > 0) {
    port.open(used[0], baudrate);
  } else {
    console.warn("No previously used serial ports found.");
  }

  setInterval(onSerialData, 50);   

  textFont(myFont);
  textSize(36);
  textAlign(CENTER, CENTER);

  cam = createCamera();
  const fov = PI / 3;
  const cameraZ = (height / 2.0) / tan(fov / 2.0);
  cam.setPosition(0, 0, cameraZ);
  cam.lookAt(0, 0, 0);
  frameRate(60);
}
function onSerialData() { //serial data
  if (!port || !port.opened()) return;
  while (port.available() > 0) {
    const raw = port.readUntil('\n');
    if (!raw) break;
    const line = raw.trim();
    console.log('Received:', line);

    if (line.startsWith('LAMP:')) {
      lampBrightness = int(line.split(':')[1]);
    } else if (line.startsWith('TOUCH:')) {
      const t = int(line.split(':')[1]);
      // ignore baseline zero readings
      if (t > 0) {
        isTouched = true;
        isWaving = true;
        waveStartTime = millis();
      }
    } else if (line === 'START') {
      interactionStarted = true;
    } else if (line === 'MUSIC_ON') {
      musicOn = true;
      if (bgMusic.isLoaded()) {
        console.log('MUSIC_ON → play');
        bgMusic.play();
      }
    } else if (line === 'MUSIC_OFF') {
      musicOn = false;
      if (bgMusic.isPlaying()) {
        console.log('MUSIC_OFF → stop');
        bgMusic.stop();
      }
    }
  }
}

//Draw
function draw() {
  background(210, 180, 140);
  ambientLight(10);
  pointLight(255, 200, 150, 200, -300, 300);
  pointLight(255, 150, 100, 400, -300, 300);

  drawWarmGradientBackground();
  drawWarmLamp();
  drawWoodDeck();
  drawWoodenBase();
  drawShadow();
  drawPot();
  drawBody();
  drawFace();
  drawPetiole();
  drawInstructionFrame();

  if (!interactionStarted) {
    drawInstructionFrame();
  } else {
    drawScene();
    pointLight(lampBrightness, lampBrightness * 0.8, 100, 200, -300, 300);
  }

  if (isWaving) {
    waveAngle = sin((millis() - waveStartTime) / 2000) * 0.5;

    if (millis() - waveStartTime > 400) {
      isWaving = false;
      waveAngle = 0;
    }
  }

  push();
  rotateZ(waveAngle);
  drawLeaf(0, -140, 0, 1, 4);
  pop();

  push();
  rotateZ(-waveAngle);
  drawLeaf(0, -140, 0, -1, 4);
  pop();

}

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  const fov = PI / 3;
  const cameraZ = (height / 2.0) / tan(fov / 2.0);
  cam.setPosition(0, 0, cameraZ);
}


function drawWarmLamp() {
  push();
  translate(250, -250, 0);

  // glow — modulate alpha by lampBrightness
  for (let r = 300; r > 50; r -= 20) {
    push();
    noStroke();
    // fade alpha between 0 and 150
    let a = map(lampBrightness, 0, 255, 20, 150);
    fill(255, 180, 90, a);
    ellipse(0, 10, r, r * 1.2);
    pop();
  }

  // stand
  fill(100, 70, 50);
  translate(0, 200, 0);
  cylinder(5, 400);

  // base
  translate(0, 200, 0);
  fill(80, 60, 40);
  ellipse(0, 0, 80, 16);

  // lampshade (inverted cone)
  push();
  translate(0, -400, 0);
  fill(225, 190, 150);
  cone(50, -100, 24);
  pop();

  pop();
}

function drawWoodDeck() {
  push();
    rotateX(HALF_PI);
    translate(0, -2, -170);
    // static plank color
    fill(160, 100, 60);
    stroke(100, 60, 40);
    const plankHeight = -50;
    for (let i = 0; i < plankCount; i++) {
      box(width, plankHeight, 10);
      translate(0, plankHeight, 0);
    }
  pop();
}

function drawWoodenBase() {
  push();
    rotateX(HALF_PI);
    translate(0, 150, -200);
    // static plank color
    fill(160, 100, 60);
    stroke(100, 60, 40);
    const baseCount = 8;
    const plankWidth = (width * 1.2) / baseCount;
    for (let i = 0; i < baseCount; i++) {
      push();
        translate(-width * 0.6 + i * plankWidth + plankWidth / 2, 24, 0);
        box(plankWidth, 400, 20);
      pop();
    }
  pop();
}


function drawShadow() {
  push();
  translate(0, 150, -10);
  rotateX(HALF_PI);
  noStroke();
  fill(80, 70, 60, 30);
  ellipse(0, 0, 190, 30);
  pop();
}

function drawPot() {
  push();
  translate(0, 100, 0);
  fill(170, 108, 57);
  stroke(120, 70, 40);
  strokeWeight(1);
  cylinder(60, 80);
  translate(0, -50, 0);
  fill(190, 120, 70);
  cylinder(80, 20);
  pop();
}

function drawBody() {
  push();
  translate(0, 20, 0);
  noStroke();
  fill(150, 255, 150);
  sphere(75);
  translate(0, -90, 0);
  sphere(70);

  // highlights
  fill(255, 255, 255, 90);
  translate(-30, -10, 50);
  sphere(10);

  pop();
}

function drawPetiole() {
  push();
  translate(0, -110, 20);  // start from top of head
  rotateX(radians(200));  // slight backward tilt
  fill(100, 200, 100);
  noStroke();
  cylinder(8, 100);       
  pop();
}


function drawLeaf(x, y, z, flip = 1, scaleFactor = 1) {
  push();
  translate(x, y, z);
  rotateZ(flip * QUARTER_PI); // tilt outwards
  rotateX(HALF_PI - QUARTER_PI * 0.5); // add a slight backward curve
  scale(flip * scaleFactor, scaleFactor); // flip + scale


  fill(100, 200, 100);
  stroke(60, 160, 80);
  strokeWeight(1);

  beginShape();
  vertex(0, 0);
  bezierVertex(-35, -90, -70, -10, 0, 0); // left curve
  endShape(CLOSE);

  // Center vein
  stroke(90, 150, 80);
  strokeWeight(2);
  line(0, 0, -40, -29);
  pop();
}

function drawFace() {
  push();
  translate(0, -100, 70);

  stroke(10);
  strokeWeight(2);
  noFill();
  arc(-20, 0, 20, 10, 0, PI);
  arc(20, 0, 20, 10, 0, PI);

  // blush
  noStroke();
  fill(255, 200, 200, 100);
  ellipse(-35, 10, 15, 8);
  ellipse(35, 10, 15, 8);

  // smile
  stroke(30);
  noFill();
  arc(0, 15, 30, 10, 0, PI);

  pop();
}
function drawWarmGradientBackground() {
  push();
  translate(0, 0, -500); // move far behind the scene
  noStroke();
  beginShape();
  fill(250, 230, 200); // top (warm cream)
  vertex(-width, -height);
  vertex(width, -height);
  fill(210, 170, 130); // bottom (warm brownish-orange)
  vertex(width, height);
  vertex(-width, height);
  endShape(CLOSE);
  pop();
}


function drawInstructionFrame() {
  push();
    // move to a spot on the back wall
    translate(-width * 0.25, -height * 0.25, -490);

    // outer frame (landscape)
    fill(120, 80, 40);
    box(430, 300, 10);

    // inner “paper” canvas inset
    push();
      translate(0, 0, 7);
      fill(255, 245, 220);
      box(390, 260, 2);
    pop();

    // text
    push();
      translate(0, 0, 12);
      fill(60, 40, 30);
      textSize(40);
      textAlign(CENTER, CENTER);
      text("Make the Plant Happy\n- Press to play Music\n- Control the Lighting\n- Pet the plant", 0, 0);
    pop();
  pop();
}

Functionality Flow

    1. Startup

      • Arduino sends MUSIC_OFF. p5.js opens port, waits for START (button press on avatar).

    2. Interaction

      • Touch: Plant touch → TOUCH:<value> → leaf animation.

      • Light: Ambient changes → LAMP:<0–255> → lamp glow intensity.

      • Music: Physical push-button → MUSIC_ON/MUSIC_OFF → background music.

Challenges & Solutions

    • Serial Fragmentation: Split messages were mis-parsed → switched to single println("TOUCH:"+value) calls.

    • Sensor Hysteresis: Capacitive values vary → implemented high/low thresholds to avoid flicker.

    • Full-Screen Behavior: Music wouldn’t play in full screen → tied audio unlock to fullscreenchange event.

User Testing + Final Project

User Testing:

During user testing, the visuals weren’t clear in indicating what the project was for. The user mentioned that instructions to interact weren’t needed, but also that the purpose of the project wasn’t directly clear.

 

For technical improvements I adjusted the interval averaging to 8 samples, which improved stability but slightly delayed responsiveness. I also tested different tempoMul ranges (originally 0.2–3) and settled on 0.5–2 to keep the music and visuals within a comfortable range.

User Testing Video

For my final project, I developed an interactive audiovisual experience that transforms a user’s heart rate into dynamic music and visuals, creating a biofeedback-driven art piece. Built using p5.js, the project integrates Web Serial API to read pulse data from an Arduino-based heart rate sensor, generating a musical chord progression (Cmaj7-Am7-Fmaj7-G7) and WebGL visuals (swirling ellipses and a point field) that respond to the calculated BPM (beats per minute). Initially, I proposed a STEM-focused feature to educate users about heart rate, but I pivoted to make the music and visuals adjustable via mouse movements, allowing users to fine-tune the tempo and visual intensity interactively.

The heart rate sensor sends pulse data to p5.js, which calculates BPM to adjust the music’s tempo (chord changes, bass, kick drum) and visual animation speed. Mouse X position controls a tempo multiplier (tempoMul), scaling both music and visuals, while BPM directly influences animation speed and audio effects. The visuals were inspired by p5.js data visualization examples from class, particularly those using WebGL to create dynamic, responsive patterns. The project aims to create a meditative, immersive experience where users see and hear their heartbeat in real-time, with interactive control over the output.

Hardware:
  • Heart Rate Sensor: I used a PulseSensor connected to an Arduino Uno, wired directly to analog pin A0 without a resistor to simplify the circuit. The sensor is not attached and can freely be interacted with.
  • Fabrication: I fit the arduino cable and heart rate sensor through the cardboard sparkfun box. I avoided a finger strap due to wire fragility and inconsistent pressure, opting for a loose finger placement method.
  • Challenges: Direct wiring without a resistor may have increased noise in the pulse signal, requiring software filtering in the Arduino code. Loose finger contact sometimes caused erratic readings, so I adjusted the threshold and added a refractory period to stabilize detection.

The p5.js sketch reads serial data from the Arduino, calculates BPM, and updates music and visuals. Initially, I tried processing raw analog values in p5.js, but noise made it unreliable. After extensive debugging (around 10 hours), I modified the Arduino code to send pre-processed BPM estimates as integers (30–180 range), which streamlined p5.js logic. The mouse-driven tempoMul (mapped from mouse X) scales chord timing, note durations, and visual motion, replacing the STEM feature with an interactive control mechanism.

A significant challenge was balancing real-time BPM updates with smooth visualization. The visuals use the latest BPM, which can make animations appear jumpy if BPM changes rapidly. I averaged BPM over 8 intervals to smooth transitions, but this introduced a slight lag, requiring careful tuning. Serial communication also posed issues: the Web Serial API occasionally dropped connections, so I added robust error handling and a “Connect & Fullscreen” button for reconnection.

Arduino Code (Heart Rate Sensor):

#define PULSE_PIN A0
void setup() {
  Serial.begin(9600);
  pinMode(PULSE_PIN, INPUT);
}
void loop() {
  int pulseValue = analogRead(PULSE_PIN);
  if (pulseValue > 400 && pulseValue < 800) { // Basic threshold
    Serial.println(pulseValue);
  } else {
    Serial.println("0");
  }
  delay(10);
}
Images

 

The seamless integration of heart rate data into both music and visuals is incredibly rewarding. Seeing the ellipses swirl faster and hearing the chords change in sync with my heartbeat feels like a direct connection between my body and the art. I’m also proud of overcoming the Arduino noise issues by implementing software filtering and averaging, which made the BPM calculation robust despite the direct wiring.

Challenges Faced:
  • Arduino Code: The biggest hurdle was getting reliable pulse detection without a resistor. The direct wiring caused noisy signals, so I spent hours tuning the THRESHOLD and REFRACTORY values in the Arduino code to filter out false positives.
  • BPM Calculation: Calculating BPM in p5.js required averaging intervals (intervals array) to smooth out fluctuations, but this introduced a trade-off between responsiveness and stability. I used a rolling average of 8 intervals, but rapid BPM changes still caused slight visual jumps.
  • Visualization Balance: The visuals update based on the latest BPM, which can make animations feel abrupt if the heart rate spikes. I tried interpolating BPM changes, but this slowed responsiveness, so I stuck with averaging to balance real-time accuracy and smooth motion.
  • p5.js Visualization: Adapting WebGL examples from class to respond to BPM was tricky. The math for scaling ellipse and point field motion (visualSpeed = (bpm / 60) * tempoMul) required experimentation to avoid jittery animations while staying synchronized with the music.
  • Serial Stability: The Web Serial API occasionally dropped connections, especially if the Arduino was disconnected mid-session. Robust error handling and the reconnect button mitigated this, but it required significant testing.
Possible Improvements:
  • Smoother BPM Transitions: Implement linear interpolation to gradually transition between BPM values, reducing visual jumps while maintaining real-time accuracy.
  • Dynamic Color Mapping: Map BPM to the hue of the ellipses or points (e.g., blue for low BPM, red for high), enhancing the data visualization aspect.
  • Audio Feedback: Add a subtle pitch shift to the pad or bass based on BPM to make tempo changes more audible.
  • Sensor Stability: Introduce a clip-on sensor design to replace loose finger placement, improving contact consistency without fragile wires.
Reflection + Larger Picture:

This project explores the intersection of biofeedback, art, and interactivity, turning an invisible biological signal (heart rate) into a tangible audiovisual experience. It highlights the potential of wearable sensors to create personalized, immersive art that responds to the user’s physical state. The data visualization component, inspired by p5.js examples from class (e.g., particle systems and dynamic patterns), emphasizes how abstract data can be made expressive and engaging. Beyond art, the project has applications in mindfulness, where users can regulate their heart rate by observing its impact on music and visuals, fostering a deeper connection between the body and mind.

Final Project: Repeat After Me

Finally, we reached the end of the semester, and with it came the submission of the final project. I had decided to make a Simon-Says style game, using lights and buzzers to interact with the user and test their recall skills. An interesting thing I’ve found throughout this course that I seem to really enjoy memory-style games, as with my midterm, and now this.

Like with my midterm, I began by creating a prototype to make sure I got the basic features down before I integrated any fancy features or graphics. Looking back on it now, the initial version worked, but it just looked, in simple terms, boring.

 

The initial gameplay didn’t feel like anything I would be excited to play at all.

The initial wiring setup didn’t inspire much confidence either.

But that’s the great part of a prototype. It didn’t need to look good, so long as it functioned well. I was able to nail down the game states with the prototype, then I began working on graphics.

I wanted to go for a retro style with my game, and so I tried to make the backgrounds and the board match a cohesive neon-arcade-esque vibe.

In the end, we arrived at the final submission. I ended up soldering many of the wires inside my box to hide them as much as possible, because attractive things work better (reading reference!).

After carrying out user-testing, I ended up integrating more features within my code, including an instructions screen, and more interactivity between the buttons (a shorter debounce delay, the buttons lighting up, etc).

And here we are. With the final submission! I had  both  a great  and  frustrating  experience  making  it,  but I’m  really  glad  with the  final result.

Schematic of my circuit (though I used arcade buttons)

// establishing variables
const int buttonPins[] = {2, 3, 4, 5}; // Yellow, green, blue, and red
const int ledPins[] = {8, 9, 10, 11}; // Yellow, green, blue, and red
const int buzzerPin = 6; // Buzzer set on pin 6

bool ledBlinking = false; // Checks whether the LEDs are blinking or not
unsigned long lastBlinkTime = 0; // Tracks when the LEDs were last blinked
bool blinkState = false; // Toggles between on and off when the LEDs are blinking

void setup() {
  // Setting up serial communication
  Serial.begin(9600); // Buad rate of 9600
  for (int i = 0; i < 4; i++) {
    // Setting the pin modes for the buttons, LEDs, and buzzer
    pinMode(buttonPins[i], INPUT_PULLUP);
    pinMode(ledPins[i], OUTPUT);
  }
  pinMode(buzzerPin, OUTPUT);
}

void loop() {
  // Handle blinking mode
  if (ledBlinking && millis() - lastBlinkTime > 500) { 
    blinkState = !blinkState; // Alternates between the LED being on and off every 500ms
    for (int i = 0; i < 4; i++) {
      if (blinkState) {
        digitalWrite(ledPins[i], HIGH); // Turn the LED on if blinkState is true
        } 
      else {
        digitalWrite(ledPins[i], LOW); // Turn the LED off if blinkState is false
        }
       }
    lastBlinkTime = millis();
  }

  // Check button presses
  for (int i = 0; i < 4; i++) {
    if (digitalRead(buttonPins[i]) == LOW) {
      Serial.println(buttonPins[i]); // Send button pin number to p5
      delay(100); // Debounce delay
    }
  }

  // Handle serial input from p5
  if (Serial.available()) {
    String command = Serial.readStringUntil('\n');
    command.trim();

    if (command.startsWith("ALL")) { // if the p5 command sends "ALL", all LEDs must be on
      int mode = command.substring(3).toInt();
      handleAllLEDs(mode);
    } 
    else if (command == "WRONG") { // if the p5 command sends "WRONG", play the sound
      tone(buzzerPin, 100, 500); // Wrong answer sound
    } 
    else {
      int pin = command.toInt(); // lights up the corresponding LED and plays the sound
      if (pin >= 8 && pin <= 11) {
        playColorFeedback(pin);
      }
    }
  }
}

// Turns on the LED corresponding to the button, and plays the sound
void playColorFeedback(int pin) {
  digitalWrite(pin, HIGH);
  playToneForPin(pin);
  delay(300);
  digitalWrite(pin, LOW);
  noTone(buzzerPin);
}

// Plays a specific tone based on the button pressed
void playToneForPin(int pin) {
  switch (pin) {
    case 8: tone(buzzerPin, 262); break; // Yellow is C4
    case 9: tone(buzzerPin, 330); break; // Green is E4
    case 10: tone(buzzerPin, 392); break; // Blue is G4
    case 11: tone(buzzerPin, 523); break; // Red is C5
  }
}

void handleAllLEDs(int mode) {
  ledBlinking = false;
  for (int i = 0; i < 4; i++) {
    digitalWrite(ledPins[i], LOW); // the LEDs are off
  }

  if (mode == 1) {
    for (int i = 0; i < 4; i++) {
      digitalWrite(ledPins[i], HIGH); // if the mode is 1, it turns on all the LEDs
    }
  } else if (mode == 2) { // if the mode is 2, it blinks the LEDs
    ledBlinking = true;
    lastBlinkTime = millis();
  }
}

My Arduino Code

Link to the full screen version

Thanks for a great semester!

Final Project Documentation

Concept

The Smart House System is an interactive physical computing project that simulates features of an intelligent home using Arduino UNO and p5.js. The system includes:

  • A smart parking assistant that detects cars entering and exiting, and updates available parking slots automatically.

  • A light automation system that turns on indoor lights when it gets dark, based on ambient light readings.

  • A real-time dashboard and voice announcer, implemented in p5.js, that visualizes the system state and speaks updates aloud using p5.speech.

This system provides a fun and engaging way to demonstrate real-world home automation, combining sensors, outputs, and visual/voice feedback for user interaction.

Interaction Demo

IMG_9078

How the Implementation Works

The system uses ultrasonic distance sensors to detect when a vehicle is near the entry or exit of the parking area. A servo motor simulates the gate that opens when a car arrives and parking is available.

A photoresistor (LDR) detects light levels to automatically turn on five LEDs that simulate indoor lighting when it gets dark.

All event messages from Arduino are sent to a p5.js sketch over web serial. The browser-based sketch then:

  • Displays the parking status

  • Shows light status

  • Uses p5.speech to speak real-time messages like “Parking is full!” or “Lights are now on!”

Interaction Design

The project is designed for simple, touchless interaction using real-world analog sensors:

  • Bringing your hand or an object close to the entry sensor simulates a car arriving. If space is available, the gate opens, the slot count is reduced, and a voice announces the update.

  • Moving your hand in front of the exit sensor simulates a car leaving, increasing the parking availability.

  • Covering the LDR sensor simulates nighttime — lights automatically turn on, and the system announces it.

  • The p5.js dashboard shows real-time status and acts as an interactive voice feedback system.

Arduino Code

The Arduino UNO is responsible for:

  • Reading two ultrasonic sensors for car entry/exit

  • Reading the photoresistor (LDR) for light level

  • Controlling a servo motor for the gate

  • Controlling 5 indoor LEDs

  • Sending status messages to the p5.js sketch over serial

Code Overview:

  • Starts with 3 available parking slots

  • Gate opens and slot count decreases when a car is detected at entry

  • Slot count increases when a car exits

  • Indoor lights turn on when light level drops below a threshold

  • Sends messages like car_entry, car_exit, parking_full, lights_on, lights_off, and parking_spots:X

#include <Servo.h>

// Ultrasonic sensor pins
#define trigEntry 2
#define echoEntry 3
#define trigExit 4
#define echoExit 5

// Servo motor pin
#define servoPin 6

// LED pins
int ledPins[] = {7, 8, 9, 10, 11};

// Light sensor pin
#define lightSensor A0

Servo gateServo;
int Slot = 3; // Initial parking spots

void setup() {
  Serial.begin(9600);

  // Ultrasonic sensors
  pinMode(trigEntry, OUTPUT);
  pinMode(echoEntry, INPUT);
  pinMode(trigExit, OUTPUT);
  pinMode(echoExit, INPUT);

  // LED pins
  for (int i = 0; i < 5; i++) {
    pinMode(ledPins[i], OUTPUT);
  }

  // LDR analog input
  pinMode(lightSensor, INPUT);

  // Servo
  gateServo.attach(servoPin);
  gateServo.write(100); // Gate closed
}

void loop() {
  int entryDistance = getDistance(trigEntry, echoEntry);
  int exitDistance  = getDistance(trigExit, echoExit);
  int lightValue    = analogRead(lightSensor); // 0 (dark) to 1023 (bright)

  Serial.print("Entry: "); Serial.print(entryDistance);
  Serial.print(" | Exit: "); Serial.print(exitDistance);
  Serial.print(" | Light: "); Serial.print(lightValue);
  Serial.print(" | Slots: "); Serial.println(Slot);

  // ===== Car Entry Logic =====
  if (entryDistance < 10 && Slot > 0) {
    openGate();
    Slot--;
    Serial.println("car_entry");
    Serial.print("parking_spots:");
    Serial.println(Slot);
    delay(2000);
    closeGate();
  }
  // ===== Parking Full Logic =====
  else if (entryDistance < 10 && Slot == 0) {
    Serial.println("parking_full");
    delay(1000); // Prevent spamming the message
  }

  // ===== Car Exit Logic =====
  if (exitDistance < 10 && Slot < 3) {
    openGate();
    Slot++;
    Serial.println("car_exit");
    Serial.print("parking_spots:");
    Serial.println(Slot);
    delay(2000);
    closeGate();
  }

  // ===== Light Control (5 LEDs) =====
  if (lightValue < 900) { // It's dark
    for (int i = 0; i < 5; i++) {
      digitalWrite(ledPins[i], HIGH);
    }
    Serial.println("lights_on");
  } else {
    for (int i = 0; i < 5; i++) {
      digitalWrite(ledPins[i], LOW);
    }
    Serial.println("lights_off");
  }

  delay(500);
}

// ===== Gate Functions =====
void openGate() {
  gateServo.write(0);
  delay(1000);
}

void closeGate() {
  gateServo.write(100);
  delay(1000);
}

// ===== Distance Sensor Function =====
int getDistance(int trigPin, int echoPin) {
  digitalWrite(trigPin, LOW);
  delayMicroseconds(2);
  digitalWrite(trigPin, HIGH);
  delayMicroseconds(10);
  digitalWrite(trigPin, LOW);
  long duration = pulseIn(echoPin, HIGH);
  int distance = duration * 0.034 / 2;
  return distance;
}

Circuit Schematic

p5.js Code and Dashboard

The p5.js sketch:

  • Uses the p5.webserial library to connect to Arduino

  • Uses p5.speech for voice announcements

  • Displays a dashboard showing the number of available parking slots

  • Shows the indoor light status using a colored circle

The voice announcements are fun and slightly humorous, e.g.:

“A wild car appears!”
“Uh-oh! Parking is full.”
“It’s getting spooky in here… turning the lights on!”

The sketch uses a say() wrapper function to safely trigger voice output in Chrome after the user clicks once.

Code Highlights:

  • Automatically resumes Chrome’s audio context

  • Waits for user interaction before enabling speech

  • Processes serial messages one line at a time

  • Provides a Connect/Disconnect button for user control

Arduino and p5.js Communication

The communication uses Web Serial API via p5.webserial:

  • Arduino sends messages like "car_entry\n", "lights_on\n", etc.

  • p5.js reads each line, processes it, updates the dashboard, and speaks it out loud

  • A connect button in the sketch allows users to select their Arduino port manually

  • All communication is unidirectional: Arduino → p5.js

What I’m Proud Of

  • Fully working sensor-triggered voice feedback via p5.js — makes the system feel alive

  • Smooth parking logic with entry and exit detection

  • Integration of multiple Arduino components (servo, LDR, LEDs, ultrasonic)

  • An intuitive UI that works both visually and with voice

  • Reliable browser-based connection using modern Web Serial

Areas for Future Improvement

  • Add a screen-based parking spot display (e.g., 7-segment or OLED)

  • Use non-blocking code in Arduino with millis() instead of delay()

  • Make a mobile-responsive version of the dashboard UI

  • Add a security camera feed or face detection in p5.js

  • Improve the servo animation to be smoother and time-synced

  • Add a buzzer or alert when parking is full

Final Project Documentation

Concept:
My final project is a bomb defusal game inspired by Keep Talking and Nobody Explodes. Just like in the original game, the player has to disarm several modules on the bomb in order to successfully defuse it. Currently it includes three types of modules: The first is Simon Says, using four LED arcade buttons. The second is an adaptation of cutting wires, where the player will have to either disconnect or rearrange the wires correctly. The last module requires the user to use a potentiometer as a tuning knob and try to hone in on the correct frequency. Once all three modules are disarmed, the bomb is defused and the game ends.Image, Video

 

Implementation:

Arduino:

  • Reads button presses, potentiometer values, and wire states.
  • Blinks arcade button LEDs to display the current Simon Says sequence, and activates the green LED on each module to indicate that they have been disarmed. 
  • Uses a piezo buzzer to audibly indicate how much time remains, which helps add tension.
  • The UNO and two breadboards are contained inside the cardboard shell, and the inputs are mounted on top.

p5.js:

  • Renders a representation of the physical bomb, including module status.
  • Displays the countdown timer since the LCD screen from the kit was not used.
  • Handles initializing each new round, including options for difficulty.
  • Randomly generates the solution for each module (e.g. color sequence for Simon Says, sweet spot for potentiometer).

 

Interaction Design:

The player is presented with three modules, which can be independently disarmed in any order. As the modules are disarmed, green status LEDs light up to indicate that the user has succeeded and can move on. Once all three modules are disarmed, p5.js will halt the timer and display a win screen. If the player fails to defuse the bomb in time, they will instead see a loss screen.

  • Simon Says: Flashes a sequence of colors on the arcade buttons, increasing in length with each successful input. If the player makes an incorrect input or fails to respond within a set amount of time, the sequence will repeat. The length of the sequence is determined by the difficulty selected.
  • Tuning: A value within the potentiometer’s viable range is randomly chosen. The player moves the knob, and once it comes within a certain range of the target value it begins a short countdown while displaying a progress bar. Both the current and target values are visualized using the sin function. The leniency range is also determined by difficulty.
  • Wires: The player must figure out the correct sequence to connect the four wires. They are not penalized for attempts in this adaptation, so they are free to use trial-and-error. A rendered visual helps guide them towards the correct configuration.

 

Schematic:

 

Arduino Code:

The Arduino code is fairly straightforward. It has a few functions used in the main loop to send/receive control messages, check the relevant inputs, and handle timing for the audiovisual components.

/*
Final Project (WIP)
By Matthias Kebede
*/





// // // Global Variables
const int valueCount = 9;
int active = 0;

// // Inputs
const int potPin = A4;
const int wirePins[4] = {A0, A1, A2, A3};
const int blueButtonIn = 6;
const int redButtonIn = 7;
const int yellowButtonIn = 8;
const int greenButtonIn = 9;

// // Input stuff
int inputs[valueCount] = {potPin, wirePins[0], wirePins[1], wirePins[2], wirePins[3], blueButtonIn, redButtonIn, yellowButtonIn, greenButtonIn};
int inputVals[valueCount] = {-1, -1, -1, -1, -1, -1, -1, -1, -1};
float smoothVals[5] = {0, 0, 0, 0, 0};
char headers[valueCount][13] = {
  {"TUNE:POT"}, {"WIRES:W1"}, {"WIRES:W2"}, {"WIRES:W3"}, {"WIRES:W4"},
  {"SIMON:BLUE"}, {"SIMON:RED"}, {"SIMON:YELLOW"}, {"SIMON:GREEN"}
};

// // Outputs
const int speakerPin = A5;
const int blueButtonOut = 2;
const int redButtonOut = 3;
const int yellowButtonOut = 4;
const int greenButtonOut = 5;
const int simonLED = 10;
const int wiresLED = 11;
const int tuneLED = 12;

// // Output Information
const int beepFreq = 2000;   // hz
const int beepDur = 50;   // ms
int beepInterval = 500;   // ms
int lastBeepTime = 0;   // ms
const int simonBlink = 350;   // ms

// // Misc.
// Keep time for Simon Says lights
struct Blink {
  int pin;
  bool lit;
  long offTime;
};
Blink simonLights[] = {
  {blueButtonOut, false, 0},
  {redButtonOut, false, 0},
  {yellowButtonOut, false, 0},
  {greenButtonOut, false, 0}
};
// Wire thresholds
const int TH0 = (1000 + 928) / 2;   // 964
const int TH1 = (928  + 512) / 2;   // 720
const int TH2 = (512  +  92) / 2;   // 302
// For analog smoothing
const float alpha = 0.2;
const int potDelta = 4;





// // // Main Processes
void setup() {
  Serial.begin(9600);

  // // Inputs and Outputs
  pinMode(potPin, INPUT);
  pinMode(blueButtonIn, INPUT);
  pinMode(redButtonIn, INPUT);
  pinMode(yellowButtonIn, INPUT);
  pinMode(greenButtonIn, INPUT);
  for (int i = 0; i < 4; i++) {
    pinMode(wirePins[i], INPUT);
  }
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(speakerPin, OUTPUT);
  pinMode(blueButtonOut, OUTPUT);
  pinMode(redButtonOut, OUTPUT);
  pinMode(yellowButtonOut, OUTPUT);
  pinMode(greenButtonOut, OUTPUT);
  pinMode(simonLED, OUTPUT);
  pinMode(wiresLED, OUTPUT);
  pinMode(tuneLED, OUTPUT);

  // // Check built-in LED
  digitalWrite(LED_BUILTIN, HIGH);
  delay(200);
  digitalWrite(LED_BUILTIN, LOW);

  // // Temp check
  digitalWrite(blueButtonOut, HIGH);
  digitalWrite(redButtonOut, HIGH);
  digitalWrite(yellowButtonOut, HIGH);
  digitalWrite(greenButtonOut, HIGH);
  delay(200);
  digitalWrite(blueButtonOut, LOW);
  digitalWrite(redButtonOut, LOW);
  digitalWrite(yellowButtonOut, LOW);
  digitalWrite(greenButtonOut, LOW);

  // // Start handshake w/ p5.js
  while (Serial.available() <= 0) {
    digitalWrite(LED_BUILTIN, HIGH);
    Serial.println("Waiting for data..."); // identifiable starting number
    delay(300);
    digitalWrite(LED_BUILTIN, LOW);
    delay(50);
  }
}

void loop() {
  // // Wait for p5.js
  while (Serial.available()) {
    digitalWrite(LED_BUILTIN, HIGH);

    String target = Serial.readStringUntil('=');
    int value = Serial.parseInt();
    if (Serial.read() == '\n') {
      writeTarget(target, value);
    }

    digitalWrite(LED_BUILTIN, LOW);
  }

  // // Send data to p5.js
  for (int i = 0; i < valueCount; i++) {
    checkValue(i);
    delay(1);
  }

  // // Clear Simon Says lights
  clearSimon();

  // // Play beeps
  if (active) timerSound();

  // // // Temp read wires
  // Serial.print(analogRead(A0)); Serial.print(',');
  // Serial.print(analogRead(A1)); Serial.print(',');
  // Serial.print(analogRead(A2)); Serial.print(',');
  // Serial.println(analogRead(A3));
}





// // // Helper Functions
// // Check current input values and compare to last known value
void checkValue(int index) {
  // // Check value // Wires: 100=1000, 1k=928, 100k=91, 10k=512   <-- W1, W2, W3, W4
  int checking;
  if (index < 1) { // < 5
    // // Add delay and smoothing for analog reads
    delay(1);
    checking = analogRead(inputs[index]);
    smoothVals[index] = alpha * checking + (1 - alpha) * smoothVals[index];
    checking = int(smoothVals[index]);
    // // Check if pot has significant change
    if (abs(checking - inputVals[index]) >= potDelta) {
      inputVals[index] = checking;
      Serial.print(headers[index]);
      Serial.print('=');
      // // Send pot value
      if (index == 0) {
        Serial.println(checking);
      }
      // // Send index of wire connection
      // else {
      //   Serial.println(identifyWire(checking));
      // }
    }
  }
  // else if (index < 5) {
  //   delay(1);
  //   checking = analogRead(inputs[index]);
  //   smoothVals[index] = alpha * checking + (1 - alpha) * smoothVals[index];
  //   checking = int(smoothVals[index]);
  //   int binaryVal = digitalWire(checking);
  //   if (abs(checking - inputVals[index]) >= potDelta && binaryVal != inputVals[index]) {
  //     inputVals[index] = binaryVal;
  //     Serial.print(headers[index]);
  //     Serial.print('=');
  //     Serial.println(binaryVal);
  //   }
  // }
  else {
    checking = digitalRead(inputs[index]);
    // // Compare
    if (checking != inputVals[index]) {
      inputVals[index] = checking;
      Serial.print(headers[index]);
      Serial.print('=');
      Serial.println(checking);
    }
  }
}

// // Handle writing to the target pin
void writeTarget(String target, int value) {
  if (target == "ACTIVE") {
    active = value;
  }
  else if (target == "BUILTIN") {
    digitalWrite(LED_BUILTIN, value);
    delay(150);
    digitalWrite(LED_BUILTIN, LOW);
  }
  // // Change beep interval based on p5.js timer
  else if (target == "BEEP") {
    beepInterval = value;
  }
  // // Simon Says
  else if (target == "SIMON") {
    digitalWrite(simonLED, value); // // Simon Says = defused
  }
  else if (target == "BLUE") {
    flashSimon(blueButtonOut);
  }
  else if (target == "RED") {
    flashSimon(redButtonOut);
  }
  else if (target == "YELLOW") {
    flashSimon(yellowButtonOut);
  }
  else if (target == "GREEN") {
    flashSimon(greenButtonOut);
  }
  // // Wires
  else if (target == "WIRES") {
    digitalWrite(wiresLED, value);
  }
  // // Tune
  else if (target == "TUNE") {
    digitalWrite(tuneLED, value);
  }
}

// // Play beeping noise
void timerSound() {
  if (lastBeepTime > beepInterval) {
    // // Reset
    lastBeepTime = 0;
    noTone(speakerPin);
    // // Play
    tone(speakerPin, beepFreq, beepDur);
  }
  else {
    lastBeepTime++;
  }
}

// // Non-blocking flash for Simon Says
void flashSimon(int pin) {
  for (auto &btn : simonLights) {
    if (btn.pin == pin) {
      digitalWrite(pin, HIGH);
      btn.lit  = true;
      btn.offTime = millis() + simonBlink;
      break;
    }
  }
}
void clearSimon() {
  long now = millis();
  for (auto &btn : simonLights) {
    if (btn.lit && now >= btn.offTime) {
      digitalWrite(btn.pin, LOW);
      btn.lit = false;
    }
  }
}

// // // Determine wire connections
// int identifyWire(int val) {
//   // if (val < 25) return -1;   // unplugged or other issue
//   if (val > TH0) return 0;   // 100 ohm
//   else if (val > TH1) return 1;   // 1k ohm
//   else if (val > TH2) return 3;   // 10k ohm
//   else return 2;   // 100k ohm // remember bottom-up order is 100, 1k, 100k, 10k
// }
// int digitalWire(int val) { // 92, 512, 928, 1000
//   if (val < 50) return 0;
//   if (val <  110 && val > 70) return 1;
//   if (val < 530 && val > 480) return 1;
//   if (val < 950 && val > 905) return 1;
//   if (val < 1024 && val > 975) return 1;
//   return 0;
// }

 

p5.js Code:

p5.js handles the actual game logic, and generates unique solutions for the modules every time a game starts. The Game class contains an array of modules, and continuously calls their update methods. Each module has its own child class extending the Module class, and contains the code for its own specific mechanics. The user can select a difficulty level from the main menu, and start a game.

 

Serial Communication:

The protocol used here follows the basic idea from the in-class examples, reading up until it reaches a newline character. In order to avoid sending the state of every input device to p5.js with every message, I broke things down into messages of the format `HEADER=value`. I mainly used this to indicate which module was sending input data, and combined it with a switch statement to separate things out on the p5.js side. In terms of transmitting to Arduino, I followed a similar idea but only had to send messages to disarm modules (e.g. `SIMON=0`) or defuse the bomb itself to stop the beeping. I also used this to have p5.js increase the frequency of the beeping when its timer reached certain breakpoints.

 

What I’m Proud Of:

I was happy with a number of decisions I made. For one, I was able to cleanly use OOP to separate out the p5.js logic for my modules while including overlapping functionality like disarming. I was also proud of the protocol I came up with for serial communication. It gave me a lot of trouble at first, so it was very fulfilling to end up with a lightweight method where I could direct messages exactly where they needed to go. Lastly, I was proud of my Simon Says module in particular. I spent a lot of time on it early on since it was the first to be implemented, and I feel like it came out the best. I had to figure out how the arcade buttons work and soldered all the necessary wires, but it was worth it since it is probably the most engaging module.

 

Areas for Improvement:

In contrast to Simon Says, I was really disappointed by my Wires module. It was originally the one I was most excited about, since this gave me a chance to actualize the experience of the inspiring game in a unique way. However, I tried a number of different ways to implement it that all failed in the end. My first attempt was to use resistors of different values and use analogRead() to determine which wire was plugged in where. However, the floating values when the wires were unplugged threw things off too much.

Another area for improvement would be the design of the bomb. Using cardboard turned out just fine, but laser cutting a wooden box might have looked more professional. I put a lot of time and effort into the initial construction, especially since I cut the cardboard down by hand, but after that I became far too busy to add any decoration and finishing touches. The p5.js visuals also suffered a bit for the same reason. There was one choice I made that I’m still on the fence about, which was to omit an explosion sound when the player loses a round. It would have been a nice touch, but I already drove myself crazy listening to the beeping sound, and I felt that having the beep frequency pick up speed was sufficient by itself.

Week 14 – Final Project

Flock Together: Gesture-Controlled Boids

Concept:

This interactive project brings the beautiful patterns of flocking behavior to life through a blend of digital and physical interactions. Drawing inspiration from Craig Reynolds’ classic Boids algorithm, the simulation creates an emergent collection of entities that move with collective “intelligence” (just like birds). What makes this implementation special is how it places control directly in your hands (literally).

Using hand tracking technology, you can shape the flock by pinching different fingers against your thumb: switch between triangles, circles, squares, and stars with your left hand, or adjust the flocking parameters with your right. The experience extends beyond the screen through an Arduino connection, where turning a physical knob changes the speed of the entire flock, and buttons let you add or remove boids in groups. The result is a meditative yet playful experience that bridges the digital and physical worlds, inviting you to explore how simple rules can create complex, beautiful patterns that respond intuitively to your gestures and touch.

Implementation Details:

I put together a fun web demo of Craig Reynolds’ Boids algorithm that you can actually mess with using your hands, your mouse, or even an Arduino knob. Behind the scenes there’s a Flock class full of little boids that follow separation, alignment, and cohesion rules you can tweak on the fly. You can choose triangles, circles, squares, or stars, and twist a physical potentiometer (via p5.webserial.js) to speed them up from half-speed to 5x, or hit “ADD” or “REMOVE” commands to add or remove 5 boids. In the browser, I use ML5.js’s HandPose model to spot pinch gestures, pinch with your left hand to swap shapes, pinch with your right to dial in the steering forces, and I run the video offscreen so everything stays buttery smooth. The canvas automatically resizes to fit your window, shows a chill gradient background that subtly reacts to your hardware tweaks, and even displays connection status and boid count. If hand tracking hiccups or the Arduino disconnects, it just auto-reconnects so the flock party never has to stop 🙂

Interaction Design:

The application uses ML5’s handpose detection to implement a natural gestural interface:

  1. Left Hand Controls Shape Selection:
    • Index finger + thumb pinch: Triangle shape (shape 0)
    • Middle finger + thumb pinch: Circle shape (shape 1)
    • Ring finger + thumb pinch: Square shape (shape 2)
    • Pinky finger + thumb pinch: Star shape (shape 3)
  2. Right Hand Controls Flocking Parameters:
    • Middle finger + thumb pinch: Increases separation force (from 1.5 to 8.0)
    • Ring finger + thumb pinch: Increases cohesion force (from 1.0 to 2.0)
    • Pinky finger + thumb pinch: Increases alignment force (from 1.0 to 2.0)

The gesture system uses a distance threshold of 20 pixels between finger and thumb to trigger actions, making the interaction intuitive and responsive.

Physical Hardware Integration

The project incorporates an Arduino with physical controls:

  1. Potentiometer Input:
    • Controls the movement speed of the boids (mapped from 0.5 to 5.0)
    • Values from Arduino (0-1023) are normalized for smooth speed control
  2. Button Controls (inferred from serial messages):
    • “ADD” command: Adds 5 new boids to the simulation
    • “REMOVE” command: Removes 5 boids from the simulation

Serial Communcation:

The serial communication in this project connects the web application with an Arduino microcontroller using the p5.webserial.js library. In sketch.js, the app initializes a serial port (createSerial()) in the setupSerial() function and attempts to connect to previously used ports. When connected (indicated by the green indicator), the application receives two types of data: commands like “ADD” and “REMOVE” that control the boid population (adding or removing 5 boids at a time), and analog values from a potentiometer (0-1023) that control the speed of the boids. The potentiometer value is normalized (mapped to a range of 0.5-5) and applied to the flock’s movement speed via the flock.updateSpeed() method. Users can also manually connect to the Arduino by clicking on the screen if the connection indicator shows red. This bidirectional communication allows physical hardware to influence the digital flocking simulation in real-time.

Schematic:

Arduino Code:

// Pin definitions
const int BUTTON_ADD_PIN = 2;     // Button to add boids
const int BUTTON_REMOVE_PIN = 3;  // Button to remove boids
const int POT_PIN = A0;           // Potentiometer for background color

// Variables to keep track of button states
int buttonAddState = HIGH;         // Current state of add button (assume pulled high)
int lastButtonAddState = HIGH;     // Previous state of add button
int buttonRemoveState = HIGH;      // Current state of remove button
int lastButtonRemoveState = HIGH;  // Previous state of remove button

// Variables for debouncing
unsigned long lastDebounceTime = 0;  
const unsigned long debounceDelay = 50;  // Debounce time in milliseconds

// Variable to store pot value
int potValue = 0;
int lastPotValue = -1;  // Store last pot value to detect significant changes

void setup() {
  // Initialize serial communication at 9600 bps
  Serial.begin(9600);
  
  // Set button pins as inputs with pullup resistors
  pinMode(BUTTON_ADD_PIN, INPUT_PULLUP);
  pinMode(BUTTON_REMOVE_PIN, INPUT_PULLUP);
  
  // No need to set analog pin mode for potentiometer
}

void loop() {
  // Read button states (LOW when pressed, HIGH when released due to pullup)
  int readingAdd = digitalRead(BUTTON_ADD_PIN);
  int readingRemove = digitalRead(BUTTON_REMOVE_PIN);
  
  // Check if add button state changed
  if (readingAdd != lastButtonAddState) {
    lastDebounceTime = millis();
  }
  
  // Check if remove button state changed
  if (readingRemove != lastButtonRemoveState) {
    lastDebounceTime = millis();
  }
  
  // Wait for debounce time to pass
  if ((millis() - lastDebounceTime) > debounceDelay) {
    // Update button states if they've changed
    if (readingAdd != buttonAddState) {
      buttonAddState = readingAdd;
      
      // If button is pressed (LOW), send command to add boids
      if (buttonAddState == LOW) {
        Serial.println("ADD");
      }
    }
    
    if (readingRemove != buttonRemoveState) {
      buttonRemoveState = readingRemove;
      
      // If button is pressed (LOW), send command to remove boids
      if (buttonRemoveState == LOW) {
        Serial.println("REMOVE");
      }
    }
  }
  
  // Read potentiometer value (0-1023)
  potValue = analogRead(POT_PIN);
  
  // Only send pot value if it changed significantly (to reduce serial traffic)
  if (abs(potValue - lastPotValue) > 10) {
    Serial.println(potValue);
    lastPotValue = potValue;
  }
  
  // Update last button states
  lastButtonAddState = readingAdd;
  lastButtonRemoveState = readingRemove;
  
  // Small delay to stabilize readings
  delay(10);
}

p5 code:

sketch.js:

let handPose;                            // ml5 HandPose model
const baseWidth = 1440;                  // reference canvas width
const baseHeight = 900;                  // reference canvas height
const shape = 0;                         // initial shape type (0–triangle)
                                           // 0 - triangle, 1 - circle, 2 - square, 3 - stars

let flock;                               // Flock instance
let video;                               // video capture
let port;                                // serial port
let serialConnected = false;             // serial connection flag
let potentiometerValue = 0;              // analog input from Arduino

// draw a star shape at (x,y)
function star(x, y, radius1, radius2, npoints) {
    let angle = TWO_PI / npoints;
    let halfAngle = angle / 2.0;
    beginShape();
    for (let a = 0; a < TWO_PI; a += angle) {
        // outer vertex
        vertex(x + cos(a) * radius2, y + sin(a) * radius2);
        // inner vertex
        vertex(x + cos(a + halfAngle) * radius1, y + sin(a + halfAngle) * radius1);
    }
    endShape(CLOSE);
}

class Flock {
    constructor() {
        this.boids = [];
        this.numBoids = 100;
        this.shape = shape;
        this.speedMultiplier = 1;
        this.separationWeight = 1.5;
        this.cohesionWeight = 1.0;
        this.alignmentWeight = 1.0;

        // initialize boids at random positions
        for (let i = 0; i < this.numBoids; i++) {
            this.boids.push(new Boid(random(width), random(height), this.shape));
        }
    }

    run() {
        // update each boid's behavior and render
        for (let boid of this.boids) {
            boid.run(this.boids, this.separationWeight, this.cohesionWeight, this.alignmentWeight);
        }
    }

    updateShape(shape) {
        this.shape = shape;
        // apply new shape to all boids
        this.boids.forEach(boid => boid.shape = shape);
    }

    updateSpeed(multiplier) {
        // constrain speed multiplier and update maxSpeed
        this.speedMultiplier = constrain(multiplier, 0.5, 5);
        this.boids.forEach(boid => boid.maxSpeed = 3 * this.speedMultiplier);
    }

    updateSeparation(weight) {
        // adjust separation weight
        this.separationWeight = constrain(weight, 0.5, 8);
    }

    updateCohesion(weight) {
        // adjust cohesion weight
        this.cohesionWeight = constrain(weight, 0.5, 3);
    }

    updateAlignment(weight) {
        // adjust alignment weight
        this.alignmentWeight = constrain(weight, 0.5, 3);
    }

    addBoid(boid) {
        // add a new boid
        this.boids.push(boid);
    }

    removeRandomBoid() {
        // remove one random boid if any exist
        if (this.boids.length > 0) {
            this.boids.splice(floor(random(this.boids.length)), 1);
        }
    }
}

class Boid {
    constructor(x, y, shape) {
        this.position = createVector(x, y);       // current location
        this.velocity = createVector(random(-1, 1), random(-1, 1));
        this.acceleration = createVector(0, 0);
        this.shape = shape;                       // shape type
        this.maxSpeed = 3;                        // top speed
        this.maxForce = 0.05;                     // steering limit
        this.r = 5;                               // radius for drawing
    }

    run(boids, separationWeight, cohesionWeight, alignmentWeight) {
        // flocking behavior, movement, boundary wrap, and draw
        this.flock(boids, separationWeight, cohesionWeight, alignmentWeight);
        this.update();
        this.borders();
        this.render();
    }

    applyForce(force) {
        // accumulate steering force
        this.acceleration.add(force);
    }

    flock(boids, separationWeight, cohesionWeight, alignmentWeight) {
        // calculate each flocking component
        let alignment = this.align(boids).mult(alignmentWeight);
        let cohesion  = this.cohere(boids).mult(cohesionWeight);
        let separation = this.separate(boids).mult(separationWeight);

        this.applyForce(alignment);
        this.applyForce(cohesion);
        this.applyForce(separation);
    }

    update() {
        // apply acceleration, limit speed, move, reset accel
        this.velocity.add(this.acceleration);
        this.velocity.limit(this.maxSpeed);
        this.position.add(this.velocity);
        this.acceleration.mult(0);
    }

    render() {
        // draw boid with correct shape and rotation
        let theta = this.velocity.heading() + radians(90);
        push();
        translate(this.position.x, this.position.y);
        rotate(theta);
        noStroke();
        fill(127);

        if (this.shape === 1) {
            circle(0, 0, this.r * 2);
        } else if (this.shape === 2) {
            square(-this.r, -this.r, this.r * 2);
        } else if (this.shape === 3) {
            star(0, 0, this.r, this.r * 2.5, 5);
        } else {
            // default triangle
            beginShape();
            vertex(0, -this.r * 2);
            vertex(-this.r, this.r * 2);
            vertex(this.r, this.r * 2);
            endShape(CLOSE);
        }

        pop();
    }

    borders() {
        // wrap around edges
        if (this.position.x < -this.r) this.position.x = width + this.r;
        if (this.position.y < -this.r) this.position.y = height + this.r;
        if (this.position.x > width + this.r) this.position.x = -this.r;
        if (this.position.y > height + this.r) this.position.y = -this.r;
    }

    separate(boids) {
        // steer away from close neighbors
        let perception = 25;
        let steer = createVector();
        let total = 0;

        boids.forEach(other => {
            let d = p5.Vector.dist(this.position, other.position);
            if (other !== this && d < perception) {
                let diff = p5.Vector.sub(this.position, other.position).normalize().div(d);
                steer.add(diff);
                total++;
            }
        });

        if (total > 0) steer.div(total);
        if (steer.mag() > 0) {
            steer.setMag(this.maxSpeed).sub(this.velocity).limit(this.maxForce);
        }
        return steer;
    }

    align(boids) {
        // steer to match average heading
        let perception = 50;
        let sum = createVector();
        let total = 0;

        boids.forEach(other => {
            let d = p5.Vector.dist(this.position, other.position);
            if (other !== this && d < perception) {
                sum.add(other.velocity);
                total++;
            }
        });

        if (total > 0) {
            sum.div(total).setMag(this.maxSpeed).sub(this.velocity).limit(this.maxForce);
        }
        return sum;
    }

    cohere(boids) {
        // steer toward average position
        let perception = 50;
        let sum = createVector();
        let total = 0;

        boids.forEach(other => {
            let d = p5.Vector.dist(this.position, other.position);
            if (other !== this && d < perception) {
                sum.add(other.position);
                total++;
            }
        });

        if (total > 0) {
            sum.div(total).sub(this.position).setMag(this.maxSpeed).sub(this.velocity).limit(this.maxForce);
        }
        return sum;
    }
}

function preload() {
    handPose = ml5.handPose();             // load handpose model
}

function setup() {
    createCanvas(windowWidth, windowHeight);     // full-window canvas
    video = createCapture(VIDEO);                // start video
    video.size(640, 480);
    video.style('transform', 'scale(-1, 1)');    // mirror view
    video.hide();
    handPose.detectStart(video, handleHandDetection); // begin hand detection
    flock = new Flock();                         // init flock

    setupSerial();                               // init Arduino comms
}

function draw() {
    drawGradientBackground();                    // dynamic background
    flock.run();                                 // update and render boids

    // read and handle serial input
    if (port && port.available() > 0) {
        let data = port.readUntil("\n")?.trim();
        if (data === "REMOVE") {
            for (let i = 0; i < 5; i++) flock.removeRandomBoid();
        } else if (data === "ADD") {
            for (let i = 0; i < 5; i++) flock.addBoid(new Boid(random(width), random(height), flock.shape));
        } else {
            potentiometerValue = parseInt(data);
            let norm = map(potentiometerValue, 0, 1023, 0, 1);
            flock.updateSpeed(map(norm, 0, 1, 0.5, 5));
        }
    }

    // display connection status and stats
    fill(serialConnected ? color(0,255,0,100) : color(255,0,0,100));
    noStroke();
    ellipse(25, 25, 15);
    fill(255);
    textSize(12);
    text(serialConnected ? "Arduino connected" : "Arduino disconnected", 40, 30);
    text("Boids: " + flock.boids.length, 40, 50);
    text("Potentiometer: " + potentiometerValue, 40, 70);
}

function windowResized() {
    resizeCanvas(windowWidth, windowHeight);   // adapt canvas size
}

function mouseDragged() {
    // add boid at drag position
    flock.addBoid(new Boid(mouseX, mouseY, flock.shape));
}

function mousePressed() {
    // connect to Arduino on click
    if (!serialConnected) {
        port.open('Arduino', 9600);
        serialConnected = true;
    }
}

function setupSerial() {
    port = createSerial();                     // create serial instance
    let usedPorts = usedSerialPorts();         // recall last port
    if (usedPorts.length > 0) {
        port.open(usedPorts[0], 9600);
        serialConnected = true;
    }
}

function drawGradientBackground() {
    // vertical gradient based on potentiometer
    let norm = map(potentiometerValue, 0, 1023, 0, 1);
    let c1 = color(0, 0, 0);
    let c2 = color(50, 50, 100);
    for (let y = 0; y < height; y++) {
        stroke(lerpColor(c1, c2, map(y, 0, height, 0, 1)));
        line(0, y, width, y);
    }
}

handGestures.js:

function handleHandDetection(results) {
    detectedHands = results;
    if (detectedHands.length === 0) return;

    let leftHandData = null;
    let rightHandData = null;

    // Identify left/right hands (using handedness or X position fallback)
    detectedHands.forEach(hand => {
        if (hand.handedness === 'Left') {
            leftHandData = hand;
        } else if (hand.handedness === 'Right') {
            rightHandData = hand;
        } else if (hand.keypoints[0].x > video.width / 2) {
            leftHandData = hand;
        } else {
            rightHandData = hand;
        }
    });

    if (leftHandData) handleShapeSelection(leftHandData);
    if (rightHandData) handleFlockingParameters(rightHandData);
}

function handleShapeSelection(hand) {
    const kp = hand.keypoints;
    const d = (i) => dist(kp[i].x, kp[i].y, kp[4].x, kp[4].y);

    // Pinch gestures select shape
    if (d(8) < 20) {
        flock.updateShape(0); // index-thumb
    } else if (d(12) < 20) {
        flock.updateShape(1); // middle-thumb
    } else if (d(16) < 20) {
        flock.updateShape(2); // ring-thumb
    } else if (d(20) < 20) {
        flock.updateShape(3); // pinkie-thumb
    }
}

function handleFlockingParameters(hand) {
    const kp = hand.keypoints;
    const pinch = (i) => dist(kp[i].x, kp[i].y, kp[4].x, kp[4].y) < 20;

    // Gesture-controlled forces; reset when not pinched
    flock.updateSeparation(pinch(12) ? 8   : 1.5); // middle-thumb
    flock.updateCohesion  (pinch(16) ? 2.0 : 1.0); // ring-thumb
    flock.updateAlignment (pinch(20) ? 2.0 : 1.0); // pinkie-thumb
}

What I am proud of:

I’m particularly proud of creating a multi-modal interactive system that merges computer vision, physical computing, and algorithmic art into a cohesive experience. The hand gesture interface allows intuitive control over both the visual appearance (shapes) and behavioral parameters (separation, cohesion, alignment) of the flocking simulation, bringing the mathematical beauty of emergent systems to life through natural interactions. The integration of Arduino hardware extends the experience beyond the screen, creating a tangible connection with the digital entities. I’m especially pleased with how the interaction design supports both casual exploration and more intentional control, users can quickly grasp the basic functionality through simple pinch gestures while having access to nuanced parameter adjustments that reveal the underlying complexity of the flocking algorithm.

Future improvements:

Looking ahead, I see several exciting opportunities to enhance this project. Implementing machine learning to adapt flocking parameters based on user behavior could create a more personalized experience. Adding audio feedback that responds to the flock’s collective movement patterns would create a richer multi-sensory experience. The visual aesthetics could be expanded with procedurally generated textures and particle effects that respond to Arduino sensor data. From a technical perspective, optimizing the flocking algorithm with spatial partitioning would allow for significantly more boids without performance issues. Finally, developing a collaborative mode where multiple users could interact with the same flock through different input devices would transform this into a shared creative experience, opening up possibilities for installation art contexts where audience members collectively influence the behavior of the digital ecosystem.

 

Concept

ExpressNotes is an interactive audiovisual art experience that blends music and generative visuals to foster expressive play. The project allows users to press buttons on a physical interface (Arduino with pushbuttons) to play piano notes, while dynamic visuals are generated on-screen in real-time. Each note corresponds to a unique visual form and color, turning a simple musical interaction into a creative multimedia composition. The project invites users to explore the relationship between sound and visuals, while also giving them the ability to control the visual environment through canvas color selection and a volume knob for audio modulation.

Implementation Overview

The system is composed of three core components: the Arduino microcontroller, which handles hardware input; the P5.js interface, which handles real-time visuals and audio playback; and a communication bridge between the two using the Web Serial API. The user first lands on a welcome screen featuring a soft background image and the title ExpressNotes, along with instructions and canvas customization. Upon connecting to the Arduino, users select either a black or white canvas before launching into the live performance mode. From there, pressing a button triggers both a piano note and a visual form, while a potentiometer allows for fine volume control of all audio feedback.

Interaction Design

The project emphasizes minimalism and clarity in its interaction model. The welcome screen gently guides users to make creative choices from the start by allowing them to select a canvas color, helping them set the tone for their audiovisual artwork. Once the canvas is active, each button press corresponds to a distinct musical note and is visually reflected through shape, color, and animation. Users can reset the artwork with a “Clear Canvas” button or return to the welcome screen with an “Exit to Intro” button. Additionally, users can press the ‘C’ key on their keyboard to instantly clear the screen. These layered controls enhance the sense of flow and control throughout the interaction.

Arduino Code Description

The Arduino handles seven pushbuttons and a potentiometer. Each button is mapped to a musical note—A through G—and each time a button is pressed, the Arduino sends a serial message like note:C to the connected computer. The potentiometer is used to adjust volume dynamically. Its analog value is read on every loop and sent as a message like volume:873. To avoid repeated messages while a button is held down, the code tracks the previous state of each button to only send data when a new press is detected. The complete Arduino sketch is included below:

const int potPin = A0;
const int buttonPins[] = {2, 3, 4, 5, 6, 7, 8}; // Buttons for A, B, C, D, E, F, G
const char* notes[] = {"A", "B", "C", "D", "E", "F", "G"};
bool buttonStates[7] = {false, false, false, false, false, false, false};

void setup() {
  Serial.begin(57600);
  for (int i = 0; i < 7; i++) {
    pinMode(buttonPins[i], INPUT_PULLUP);
  }
}

void loop() {
  int volume = analogRead(potPin);
  Serial.print("volume:");
  Serial.println(volume);

  for (int i = 0; i < 7; i++) {
    bool isPressed = digitalRead(buttonPins[i]) == LOW;
    if (isPressed && !buttonStates[i]) {
      Serial.print("note:");
      Serial.println(notes[i]);
    }
    buttonStates[i] = isPressed;
  }

  delay(100);
}

P5.JS Code :

let port;
let connectBtn;
let soundA, soundB, soundC, soundD, soundE, soundF, soundG;
let volume = 0.5;
let bgImage;
let isConnected = false;
let showIntro = false;
let canvasColor = 0; // 0 for black, 255 for white
let colorChoiceMade = false;
let blackBtn, whiteBtn, clearBtn, exitBtn;
let firstDraw = true;

function preload() {
  soundFormats('wav');
  soundA = loadSound('A.wav');
  soundB = loadSound('B.wav');
  soundC = loadSound('C.wav');
  soundD = loadSound('D.wav');
  soundE = loadSound('E.wav');
  soundF = loadSound('F.wav');
  soundG = loadSound('A.wav'); // Adjust if different from A
  bgImage = loadImage('background.jpg');
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  background(0);
  textAlign(CENTER, CENTER);
  port = createSerial();

  connectBtn = createButton("Connect to Arduino");
  connectBtn.position(width / 2 - 100, height / 2);
  connectBtn.style('font-size', '20px');
  connectBtn.style('padding', '15px 30px');
  connectBtn.mousePressed(connectToArduino);
}

function draw() {
  if (firstDraw) {
    background(0);
    firstDraw = false;
  }

  if (!isConnected) {
    fill(255);
    textSize(32);
    text("ExpressNotes", width / 2, height / 2 - 100);
    return;
  }

  if (isConnected && !showIntro) {
    showIntro = true;
  }

  if (showIntro && !colorChoiceMade) {
    displayIntro();
    return;
  }

  // Only handle serial data, don't clear the canvas
  let str = port.readUntil("\n");
  if (str.length > 0) {
    handleSerial(str.trim());
  }
}

function connectToArduino() {
  if (!port.opened()) {
    let used = usedSerialPorts();
    if (used.length > 0) {
      port.open(used[0], 57600);
    } else {
      port.open('Arduino', 57600);
    }
    isConnected = true;
    connectBtn.hide();
  }
}

function displayIntro() {
  tint(255, 100);
  image(bgImage, 0, 0, width, height);
  noTint();

  fill(0, 0, 0, 200);
  rect(width / 4, height / 4, width / 2, height / 2, 20);

  fill(255);
  textSize(32);
  text("ExpressNotes", width / 2, height / 4 + 50);
  textSize(16);
  text(
    "Welcome to ExpressNotes!\n\nThis interactive application lets you create\nvisual art while playing musical notes.\n\nChoose your canvas\nand start creating!",
    width / 2,
    height / 2 - 40
  );

  // Create canvas color selection buttons
  if (!blackBtn && !whiteBtn) {
    blackBtn = createButton("Black Canvas");
    blackBtn.position(width / 2 - 150, height / 2 + 80);
    blackBtn.mousePressed(() => chooseCanvasColor(0));

    whiteBtn = createButton("White Canvas");
    whiteBtn.position(width / 2 + 50, height / 2 + 80);
    whiteBtn.mousePressed(() => chooseCanvasColor(255));
  }
}

function chooseCanvasColor(colorValue) {
  canvasColor = colorValue;
  colorChoiceMade = true;
  if (blackBtn) blackBtn.remove();
  if (whiteBtn) whiteBtn.remove();
  background(canvasColor); // Only clear when changing colors

  // Show the Clear and Exit buttons after canvas selection
  showCanvasControls();
}

function showCanvasControls() {
  clearBtn = createButton("Clear Canvas");
  clearBtn.position(10, 10);
  clearBtn.mousePressed(clearCanvas);

  exitBtn = createButton("Exit to Intro");
  exitBtn.position(10, 50);
  exitBtn.mousePressed(exitToIntro);
}

function clearCanvas() {
  background(canvasColor); // Clear canvas with current background color
}

function exitToIntro() {
  background(0);
  showIntro = false;
  colorChoiceMade = false;
  clearBtn.remove();
  exitBtn.remove();
  blackBtn = null;
  whiteBtn = null;
  // Reset the intro page
  showIntro = true;
  displayIntro();
}

function handleSerial(data) {
  if (data.startsWith("note:")) {
    let note = data.substring(5);
    playNote(note);
    showVisual(note);
  } else if (data.startsWith("volume:")) {
    let val = parseInt(data.substring(7));
    volume = map(val, 0, 1023, 0, 1);
    setVolume(volume);
  }
}

function playNote(note) {
  if (note === "A") soundA.play();
  else if (note === "B") soundB.play();
  else if (note === "C") soundC.play();
  else if (note === "D") soundD.play();
  else if (note === "E") soundE.play();
  else if (note === "F") soundF.play();
  else if (note === "G") soundG.play();
}

function setVolume(vol) {
  [soundA, soundB, soundC, soundD, soundE, soundF, soundG].forEach(s => s.setVolume(vol));
}

function showVisual(note) {
  push(); // Save current drawing state
  if (note === "A") {
    fill(0, 0, 255, 150);
    noStroke();
    ellipse(random(width), random(height), 50);
  } else if (note === "B") {
    fill(255, 215, 0, 150);
    noStroke();
    triangle(random(width), random(height), random(width), random(height), random(width), random(height));
  } else if (note === "C") {
    fill(255, 0, 0, 150);
    noStroke();
    rect(random(width), random(height), 60, 60);
  } else if (note === "D") {
    stroke(0, 255, 255);
    noFill();
    line(random(width), 0, random(width), height);
  } else if (note === "E") {
    fill(0, 255, 0, 150);
    noStroke();
    ellipse(random(width), random(height), 80);
  } else if (note === "F") {
    fill(255, 105, 180, 150);
    noStroke();
    rect(random(width), random(height), 30, 90);
  } else if (note === "G") {
    stroke(255, 255, 0);
    noFill();
    line(0, random(height), width, random(height));
  }
  pop(); // Restore original drawing state
}

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  if (!isConnected) {
    connectBtn.position(width / 2 - 100, height / 2);
  }
}


function keyPressed() {
  if (key === 'c' || key === 'C') {
    background(canvasColor); // Clear canvas with current background color
  }
}



Circuit Schematic

The circuit includes seven pushbuttons connected to Arduino digital pins 2 through 8, with internal pull-up resistors enabled. Each button connects one leg to ground and the other to a digital pin. A potentiometer is connected with its middle pin going to A0 on the Arduino, while the outer pins go to 5V and GND. A small speaker or piezo buzzer is powered separately and connected to the P5.js interface for audio playback.

P5.js Code Description

The P5.js sketch handles all audio-visual feedback and interaction. Upon launching, the user is greeted with a title screen and two buttons to choose between a black or white canvas. Once the selection is made, the sketch draws continuously without clearing the background, allowing visuals from each button press to layer and evolve over time. Each button triggers a different note (using .wav files preloaded into the project) and spawns a unique visual form—such as colored circles, triangles, rectangles, and lines—with transparency effects for layering. Volume is dynamically updated from the serial input and mapped to the 0–1 range for sound control. The code uses the p5.webserial library to read serial messages from the Arduino, interpret them, and respond accordingly.

Communication Between Arduino and P5.js

Communication is established using the Web Serial API integrated into P5.js via the p5.webserial library. The Arduino sends simple serial strings indicating either the current volume or the note pressed. The P5.js sketch listens for these messages using port.readUntil("\n"), parses them, and then calls appropriate functions to play sounds or update the interface. For example, a message like note:E will trigger the playNote("E") function and then create the matching shape with showVisual("E"). This streamlined, human-readable message protocol keeps the interaction fluid and easy to debug.

Project Highlights

One of the most rewarding aspects of ExpressNotes is the tight integration between sound, visuals, and user interaction. The use of simple hardware elements to trigger complex audiovisual responses creates an accessible yet expressive digital instrument. The welcome screen and canvas selection elevate the experience beyond just utility and into a more artistic, curated space. The project has also succeeded in demonstrating how hardware and software can communicate fluidly in the browser using modern tools like Web Serial, eliminating the need for extra software installations or complex drivers.

Future Improvements

For future iterations, several enhancements could expand the expressive range of the project. First, including different instrument samples or letting users upload their own would personalize the sound experience. Second, adding real-time animation or particle effects tied to note velocity or duration could create richer visual compositions. Additionally, saving and exporting the canvas as an image or even a short video clip would let users archive and share their creations. Improving responsiveness by removing the delay in the Arduino loop and supporting multiple simultaneous button presses are also key technical upgrades to consider.

Link to video demo : https://drive.google.com/drive/folders/1so48ZlyaFx0JvT3NU65Ju6wzncWNYsBa