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):
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; } }