Concept
My concept is a semi-thriller game that features two key sensors — the joystick, and the pulse sensor. It was inspired by my siblinghood. Growing up, I would often eat my sister’s snacks, of course, without her permission. This would give me a thrill that was akin to what someone would feel playing hide-and-seek with a murderer (I’m exaggerating here; I actually really love my sister haha). I settle on making it “slime-themed” since I wanted it to be more fun and gender-neutral to be inclusive to all players (possibly people that had older brothers, cousins, etc.). I mean certainly a slime character is non-specific enough I think.
How it works
Well, when you first start the game, you will be greeted with an eery background music and screen image with two options: “how to play” and “start game”. If you press on “how to play” with the joystick, you’ll be shown a very comprehensive manual on how to play the game. Then, if you press start, it’ll take you to the game dialog, and you can click through the dialog through the joystick clicks. After that, the game begins! When it starts, on the top right of the screen there is a icon of slime chips with a number on top, these are the number of chips you need to eat without getting caught and losing all your hearts. The number of hearts you have is on the top left. When the sister turns around and you’re eating your chips (i.e. your hands are touching; you can move the right hand with the chips left and right with the joystick), she gets mad and you lose a heart. Here are the game p5.js visuals:
(startscreen) (how to play)
(dialog screen)
(game started and sister turned around)
(game started and sister caught you!!)
(you won!!)
Implementation
1. Interaction Design:
I wanted it to have a flex sensor initially as the pseudo “joystick” but I realized that that wouldn’t really be intuitive or even enjoyable for the average user likely due to its foreign nature in games like these. So I decided to borrow a joystick from the IM lab, alongside a pulse sensor. I wanted to make the game take real-time input from the user and imitate the scenario of my heart racing as a kid when stealing my sister’s food. So, every 1.5 to 2.5 seconds, the game checks if the user’s heartbeat ‘jumps’ by more than 70 (imitating nervousness). If so, or with a 25% random chance, the sister turns around. I did this to simulate the tension of being caught, as the player’s nervousness (heartbeat jump) makes the slime sibling more likely to notice you with a bit of randomness. I added chip-eating sound effects among some other sounds to reflect what is happening on the screen.
2. Arduino Code:
My Arduino code controls three pairs of colored LEDs (red, yellow, green) based on commands received from a computer (via Serial). It also reads a joystick’s X-axis, a pulse sensor, and a joystick button (I put print statements for their values for debugging and monitoring). Only one color is lit at a time, and the code ignores repeated color commands, to make sure a state is reflected clearly for the player. I also added some helper functions to keep all LEDs off at the beginning and another one so that the same color LED is on at both sides of the box. Here is the code:
const int joyXPin = A0; // joystick a-axis const int pulsePin = A1; // pulse sensor const int selPin = 8; // joystick “select” button (digital) const int baudRate = 9600; // LED pins const int RED_L = 7, RED_R = 5; const int YEL_L = 10, YEL_R = 4; const int GRN_L = 12, GRN_R = 2; const int threshold = 50; // dead-zone around center (512) // remembering last LED state String currentCmd = ""; void setup() { Serial.begin(baudRate); pinMode(selPin, INPUT_PULLUP); // LED pins pinMode(RED_L, OUTPUT); pinMode(RED_R, OUTPUT); pinMode(YEL_L, OUTPUT); pinMode(YEL_R, OUTPUT); pinMode(GRN_L, OUTPUT); pinMode(GRN_R, OUTPUT); // start with all off clearAllLeds(); } void loop() { // checking for new command from p5.js if (Serial.available()) { String cmd = Serial.readStringUntil('\n'); cmd.trim(); // only accept valid colors if (cmd == "RED" || cmd == "YELLOW" || cmd == "GREEN") { // if different than last, update if (cmd != currentCmd) { currentCmd = cmd; applyLeds(currentCmd); } } // echo back for debugging Serial.print("Received: "); Serial.println(cmd); } // existing joystick/pulse prints int xRaw = analogRead(joyXPin); int pulseRaw = analogRead(pulsePin); bool selPressed = (digitalRead(selPin) == LOW); int dirX; if (xRaw < 512 - threshold) dirX = 0; else if (xRaw > 512 + threshold) dirX = 1; else dirX = -1; Serial.print("DIRX:"); Serial.print(dirX); Serial.print(" PULSE:");Serial.print(pulseRaw); Serial.print(" SEL:"); Serial.println(selPressed ? 1 : 0); } // helper to turn all LEDs off void clearAllLeds() { digitalWrite(RED_L, LOW); digitalWrite(RED_R, LOW); digitalWrite(YEL_L, LOW); digitalWrite(YEL_R, LOW); digitalWrite(GRN_L, LOW); digitalWrite(GRN_R, LOW); } // helper to set exactly one color on both sides void applyLeds(const String &cmd) { clearAllLeds(); if (cmd == "RED") { digitalWrite(RED_L, HIGH); digitalWrite(RED_R, HIGH); } else if (cmd == "YELLOW") { digitalWrite(YEL_L, HIGH); digitalWrite(YEL_R, HIGH); } else if (cmd == "GREEN") { digitalWrite(GRN_L, HIGH); digitalWrite(GRN_R, HIGH); } }
3. Schematic of Project:
4. p5.js Code:
The game features multiple states (start menu, gameplay, how-to-play screen) managed through a state machine architecture. When playing, users control a hand to grab chips while avoiding detection from the sister character who randomly turns around. The sister’s turning behavior is influenced by both randomness and the player’s actual heartbeat measured through a pulse sensor.
Key features in the p5.js include:
- Dialog system for narrative progression
- Character animation with multiple emotional states
- Collision detection between hands
- Health and scoring systems (hearts and chip counter)
- Two endings (happy and bad) based on gameplay outcomeThis is the code:
let gameState = 'start'; // 'start', 'game', or 'howToPlay' let startImg; let bgImg; let howToPlayImg; let bgMusic; let angry; // serial communication variables let rightX = 0; let pulseValue = 0; let connectButton; // button to initiate serial connection // hoystick control variables for menu navigation let currentSelection = 0; // 0 = start game, 1 = how to play let selPressed = false; let prevSelPressed = false; // to detect rising edge let backButtonSelected = false; // tracking if back button is selected in how to play screen // hand objects let leftHand = { img: null, x: 0, y: 0, width: 150, height: 150, visible: false }; let rightHand = { img: null, x: 0, y: 0, width: 150, height: 150, visible: false, currentImg: 1, // tracking current hand image (1, 2, or 3) animationStarted: false, // tracking if animation has started animationTimer: 0, // timer for animation lastLedState: "", sisterTurned: false, // tracking if sister is turned around sisterTurnStart: 0, // ← when she last flipped to “turned” maxTurnDuration: 1700, // ← max ms she can remain turned (1.7 s) lastTurnCheck: 0, // timer for checking sister's state heartDeducted: false, // flag to track if heart was deducted in current overlap lastSisterState: false, // tracking previous sister state to detect changes sisterAngry: false, // flag to track if sister is angry lastPulseValue: 0 // tracking last pulse value for heartbeat detection }; let chipCounter = { img: null, x: 0, y: 0, width: 80, height: 80, count: 10 }; let hearts = { img: null, count: 5, width: 30, height: 30, spacing: 10, x: 20, y: 20 }; let gameOver = false; let gameEndingType = ''; // 'good' or 'bad' to track which ending to show let dialogSounds = {}; let startButton = { x: 0, y: 0, width: 150, height: 30, text: 'start game', isHovered: false }; let howToPlayButton = { x: 0, y: 0, width: 180, height: 30, text: 'how to play', isHovered: false }; let backButton = { x: 0, y: 0, width: 100, height: 40, text: 'Back', isHovered: false }; let instrumentSerifFont; let currentDialog = null; let characters = {}; function preload() { startImg = loadImage('Images/start.png'); bgImg = loadImage('Images/bg.png'); howToPlayImg = loadImage('Images/howtoplay.png'); instrumentSerifFont = loadFont('InstrumentSerif-Regular.ttf'); bgMusic = loadSound('Audios/bg.mp3'); leftHand.img = loadImage('Images/left_hand.png'); rightHand.img = loadImage('Images/right_hand1.png'); rightHand.img2 = loadImage('Images/right_hand2.png'); rightHand.img3 = loadImage('Images/right_hand3.png'); chipCounter.img = loadImage('Images/slimechips.png'); hearts.img = loadImage('Images/heart.png'); happyEndingImg = loadImage('Images/happy_ending.png'); badEndingImg = loadImage('Images/bad_ending.png'); dialogSounds['chipbag.mp3'] = loadSound('Audios/chipbag.mp3'); dialogSounds['chip_crunch.mp3'] = loadSound('Audios/chip_crunch.mp3'); dialogSounds['angry.mp3'] = loadSound('Audios/angry.mp3'); characters['sister_normal'] = new Character('sister_normal', 'Sister', 'Images/sister_normal.png'); characters['sister_speaking'] = new Character('sister_speaking', 'Sister', 'Images/sister_speaking.png'); characters['sister_turned'] = new Character('sister_turned', 'Sister', 'Images/sister_turned.png'); characters['sister_upset'] = new Character('sister_upset', 'Sister', 'Images/sister_upset.png'); characters['sister_angry'] = new Character('sister_angry', 'Sister', 'Images/sister_angry.png'); characters['slime'] = new Character('slime', 'me', 'Images/slime.png'); characters['slime_speaking'] = new Character('slime_speaking', 'me', 'Images/slime_speaking.png'); for (let key in characters) { characters[key].preload(); } } async function setUpSerialWithErrorHandling() { // handling errors with serial try { await setUpSerial(); } catch (error) { console.log("Serial connection failed: " + error.message); } } function setup() { createCanvas(627, 447); noCursor(); // playing background music bgMusic.setVolume(0.5); bgMusic.loop(); textFont(instrumentSerifFont); connectButton = createButton('connect serial'); connectButton.position(25, 25); connectButton.style('font-size', '18px'); connectButton.style('font-weight', 'bold'); connectButton.style('color', '#000'); connectButton.style('background-color', '#fff'); connectButton.style('border', '2px solid #000'); connectButton.style('border-radius', '8px'); connectButton.style('padding', '8px 16px'); connectButton.style('cursor', 'pointer'); connectButton.mouseOver(() => { connectButton.style('background-color', '#000'); connectButton.style('color', '#fff'); }); connectButton.mouseOut(() => { connectButton.style('background-color', '#fff'); connectButton.style('color', '#000'); }); connectButton.mousePressed(setUpSerialWithErrorHandling); // using setUpSerial from p5.web-serial.js positionStartScreenButtons(); positionBackButton(); rightHand.x = width - 10; rightHand.y = height/2 - rightHand.height/2; chipCounter.x = width - chipCounter.width - 20; chipCounter.y = 20; const sampleDialog = [ { speaker: 'sister_speaking', line: "Hey! Have you seen my chips?" }, { speaker: 'slime', line: "Nope, I haven't seen them." , sound: "chipbag.mp3"}, { speaker: 'sister_upset', line: "Hmmm... are you sure?" }, { speaker: 'slime_speaking', line: "Yep...! Haven't seen them! I'll let you know if I see them!" }, { speaker: 'sister_speaking', line: "Grrr! If I catch you with them..I'm going to kill you!" } ]; currentDialog = new Dialog(sampleDialog); // passing the new structure to the Dialog constructor } function readSerial(data) { let val = trim(data); if (!val) return; let parts = val.split(" "); // parsing DIRX for both game movement and menu navigation let dirPart = parts.find(p => p.startsWith("DIRX:")); if (dirPart) { let dir = int(dirPart.split(":")[1]); // setting rightX for in-game hand movement if (dir === 0) { rightX = width * 0.25; // move left // for menu navigation, select the left button (start game) if (gameState === 'start') { currentSelection = 0; // selecting start game button } // for how to play screen, select the back button when moving left else if (gameState === 'howToPlay') { backButtonSelected = true; } } else if (dir === 1) { rightX = width * 0.75; // move right // for menu navigation, select the right button (how to play) if (gameState === 'start') { currentSelection = 1; // selecting how to play button } // for how to play screen, deselect the back button when moving right else if (gameState === 'howToPlay') { backButtonSelected = false; } } else if (dir === -1) { rightX = width / 2; // center (neutral) } console.log("Joystick Direction:", dir); } // parsing SEL for button selection let selPart = parts.find(p => p.startsWith("SEL:")); if (selPart) { let sel = int(selPart.split(":")[1]); prevSelPressed = selPressed; selPressed = (sel === 1); // if SEL button was just pressed (rising edge detection) if (selPressed && !prevSelPressed) { // handling button selection based on current screen if (gameState === 'start') { if (currentSelection === 0) { startGame(); } else if (currentSelection === 1) { showHowToPlay(); } } else if (gameState === 'howToPlay') { // only going back if the back button is selected if (backButtonSelected) { goBackToStart(); } } else if (gameState === 'game' && currentDialog && !currentDialog.isComplete) { currentDialog.advanceDialog(); } } } // parsing PULSE let pulsePart = parts.find(p => p.startsWith("PULSE:")); if (pulsePart) { pulseValue = int(pulsePart.split(":")[1]); console.log("Pulse Reading:", pulseValue); } } // helper that mirrors mouseClicked() logic but uses joystick coords function joystickClick(x,y){ if(gameState==='start'){ // is it on the “start” button? if(x > startButton.x && x < startButton.x+startButton.width && y > startButton.y && y < startButton.y+startButton.height){ startGame(); } // how-to-play button? else if(x > howToPlayButton.x && x < howToPlayButton.x+howToPlayButton.width && y > howToPlayButton.y && y < howToPlayButton.y+howToPlayButton.height){ showHowToPlay(); } } } function draw() { background(255); switch (gameState) { case 'start': drawStartScreen(); updateButtonHover(startButton); updateButtonHover(howToPlayButton); break; case 'game': drawGameScreen(); updateButtonHover(backButton); break; case 'howToPlay': drawHowToPlayScreen(); updateButtonHover(backButton); break; } if (!gameOver) { noCursor(); } } function drawStartScreen() { image(startImg, 0, 0, width, height); // updating button hover state based on joystick selection instead of mouse if (gameState === 'start') { // hilighting the currently selected button based on joystick input startButton.isHovered = (currentSelection === 0); howToPlayButton.isHovered = (currentSelection === 1); } drawButton(startButton); drawButton(howToPlayButton); // hiding back button on start screen backButton.x = -backButton.width; // positioning off-screen } function drawGameScreen() { image(bgImg, 0, 0, width, height); // drawing dialog if it exists and isn’t complete if (currentDialog && !currentDialog.isComplete) { currentDialog.display(); return; } // if dialog is complete but gameover, show ending if (gameOver) { drawGameOverScreen(); return; } // --- sister character --- let sisterWidth = 180; let sisterHeight = 200; let sisterX = width/2 - sisterWidth/2; let sisterY = height/2 - sisterHeight/2 - 30; let sisterImg = characters['sister_turned'].img; if (rightHand.sisterAngry) sisterImg = characters['sister_angry'].img; else if (rightHand.sisterTurned) sisterImg = characters['sister_upset'].img; // possibly flip her based on time+random+heartbeat if (!rightHand.lastTurnCheck || millis() - rightHand.lastTurnCheck > random(1500, 2500)) { rightHand.lastTurnCheck = millis(); let heartbeatTrigger = (rightHand.lastPulseValue && Math.abs(pulseValue - rightHand.lastPulseValue) > 70); rightHand.lastPulseValue = pulseValue; if (random() < 0.25 || heartbeatTrigger) { rightHand.sisterTurned = !rightHand.sisterTurned; if (rightHand.sisterTurned) { rightHand.sisterTurnStart = millis(); } rightHand.sisterAngry = false; rightHand.heartDeducted = false; } } image(sisterImg, sisterX, sisterY, sisterWidth, sisterHeight); // enforcing max “turned” time if (rightHand.sisterTurned && millis() - rightHand.sisterTurnStart > rightHand.maxTurnDuration) { rightHand.sisterTurned = false; rightHand.sisterAngry = false; rightHand.heartDeducted = false; } // --- Collision --- leftHand.x = width/2 - leftHand.width - 50; leftHand.y = height/2 + leftHand.height/2; // compute & smooth right hand let targetX = constrain(rightX, width*0.1, width*0.9); rightHand.x += (targetX - rightHand.x)*0.2; rightHand.y = height/2 + rightHand.height/2; let handsOverlap = checkHandCollision(leftHand, rightHand); let ledState = ""; if (handsOverlap && rightHand.sisterTurned && !rightHand.heartDeducted) { // CAUGHT → angry → RED hearts.count--; rightHand.heartDeducted = true; rightHand.sisterAngry = true; ledState = "RED"; dialogSounds['angry.mp3'].play(); if (hearts.count <= 0) { gameOver = true; gameEndingType = 'bad'; } } else if (handsOverlap && rightHand.sisterTurned && rightHand.heartDeducted) { ledState = "RED"; } else if (!rightHand.sisterTurned) { // sister is not turned // SAFE while she’s turned → GREEN ledState = "GREEN"; } else if (rightHand.sisterTurned && !handsOverlap) { // sister is turned but hands are not overlapping // NORMAL (not turned) → YELLOW ledState = "YELLOW"; } if (ledState && ledState !== rightHand.lastLedState) { writeSerial(ledState + "\n"); rightHand.lastLedState = ledState; } // --- Eating Animation & Chips --- if (handsOverlap && !rightHand.animationStarted) { rightHand.animationStarted = true; rightHand.animationTimer = millis(); if (rightHand.currentImg === 3 && chipCounter.count > 0) { chipCounter.count--; if (chipCounter.count <= 0) { gameOver = true; gameEndingType = 'good'; } } rightHand.currentImg = 1; dialogSounds['chip_crunch.mp3'].play(); } else if (handsOverlap && rightHand.animationStarted) { let t = millis() - rightHand.animationTimer; if (rightHand.currentImg === 1 && t > 300) rightHand.currentImg = 2; if (rightHand.currentImg === 2 && t > 500) rightHand.currentImg = 3; if (rightHand.currentImg === 3 && t > 800) { rightHand.animationTimer = millis(); rightHand.currentImg = 1; if (chipCounter.count > 0) { chipCounter.count--; if (chipCounter.count <= 0) { gameOver = true; gameEndingType = 'good'; } } dialogSounds['chip_crunch.mp3'].play(); } } else if (!handsOverlap && rightHand.animationStarted) { rightHand.animationStarted = false; dialogSounds['chip_crunch.mp3'].stop(); } // drawing hands let handImg = rightHand.currentImg===1 ? rightHand.img : rightHand.currentImg===2 ? rightHand.img2 : rightHand.img3; image(handImg, rightHand.x, rightHand.y, rightHand.width, rightHand.height); image(leftHand.img, leftHand.x, leftHand.y, leftHand.width, leftHand.height); image(chipCounter.img, chipCounter.x, chipCounter.y, chipCounter.width, chipCounter.height); push(); fill(255); stroke(0); strokeWeight(2); textSize(24); textAlign(CENTER, CENTER); text(chipCounter.count, chipCounter.x+chipCounter.width/2, chipCounter.y+chipCounter.height/2); pop(); for (let i=0; i<hearts.count; i++) { image(hearts.img, hearts.x+(hearts.width+hearts.spacing)*i, hearts.y, hearts.width, hearts.height); } } // function to check collision between hand objects function checkHandCollision(hand1, hand2) { // rectangular collision detection return ( hand1.x + 10 < hand2.x + hand2.width && hand1.x + hand1.width > hand2.x && hand1.y < hand2.y + hand2.height && hand1.y + hand1.height > hand2.y ); } function drawHowToPlayScreen() { background(50); let imgWidth = width * 0.9; // Use 90% of screen width let imgHeight = height * 0.7; // Use 70% of screen height image(howToPlayImg, width/2 - imgWidth/2, height/2 - imgHeight/2 + 30, imgWidth, imgHeight); fill(255); textSize(35); textAlign(CENTER, CENTER); text("How to Play", width/2, height/2 - imgHeight/2 - 15); backButton.isHovered = backButtonSelected; drawButton(backButton); startButton.x = -startButton.width; // Position off-screen howToPlayButton.x = -howToPlayButton.width; // Position off-screen } function startGame() { gameState = 'game'; console.log("Game Started!"); if (bgMusic.isPlaying()) { bgMusic.stop(); } if (currentDialog) { currentDialog.currentLineIndex = 0; currentDialog.isComplete = false; } positionBackButton(); } function showHowToPlay() { gameState = 'howToPlay'; console.log("Showing How to Play"); positionBackButton(); } function goBackToStart() { gameState = 'start'; console.log("Going back to Start Screen"); positionStartScreenButtons(); } // helper function to position buttons on the start screen function positionStartScreenButtons() { textSize(20); startButton.width = textWidth(startButton.text) + 40; howToPlayButton.width = textWidth(howToPlayButton.text) + 40; startButton.height = 40; howToPlayButton.height = 40; let buttonY = height * 0.65; // Moved buttons higher (was 0.75) let buttonSpacing = 20; let totalButtonWidth = startButton.width + howToPlayButton.width + buttonSpacing; let leftShift = 15; startButton.x = width / 2 - totalButtonWidth / 2 - leftShift; howToPlayButton.x = startButton.x + startButton.width + buttonSpacing; startButton.y = buttonY; howToPlayButton.y = buttonY; } function positionBackButton() { textSize(20); backButton.width = textWidth(backButton.text) + 40; backButton.height = 40; backButton.x = 20; backButton.y = 20; } // function to draw a button function drawButton(button) { if (button.x + button.width > 0 && button.x < width) { push(); // Save current drawing settings if (button.isHovered) { fill(255, 0, 0); } else { fill(0); } rect(button.x, button.y, button.width, button.height, 10); // 10 is corner radius fill(255); textSize(22); textAlign(CENTER, CENTER); text(button.text, button.x + button.width / 2, button.y + button.height / 2 - 5); pop(); // restoring drawing settings } } // helper function to update button hover state function updateButtonHover(button) { // inly update hover if the button is likely visible on screen if (button.x + button.width > 0 && button.x < width) { if (mouseX > button.x && mouseX < button.x + button.width && mouseY > button.y && mouseY < button.y + button.height) { button.isHovered = true; cursor(HAND); // Change cursor on hover } else { button.isHovered = false; // only set cursor back to ARROW if NO button is hovered let anyButtonHovered = false; if (gameState === 'start') { if (startButton.isHovered || howToPlayButton.isHovered) anyButtonHovered = true; } else if (gameState === 'howToPlay' || gameState === 'game') { if (backButton.isHovered) anyButtonHovered = true; } if (!anyButtonHovered) { cursor(ARROW); } } } } // helper function to update button hover state with joystick function updateButtonHoverWithJoystick(button, joyX, joyY) { if (button.x + button.width > 0 && button.x < width) { if (joyX > button.x && joyX < button.x + button.width && joyY > button.y && joyY < button.y + button.height) { button.isHovered = true; } else { button.isHovered = false; } } } function mouseClicked() { switch (gameState) { case 'start': // Check for button clicks on the start screen if (mouseX > startButton.x && mouseX < startButton.x + startButton.width && mouseY > startButton.y && mouseY < startButton.y + startButton.height) { startGame(); } else if (mouseX > howToPlayButton.x && mouseX < howToPlayButton.x + howToPlayButton.width && mouseY > howToPlayButton.y && mouseY < howToPlayButton.y + howToPlayButton.height) { showHowToPlay(); } break; case 'game': // handling dialog clicks in the game screen if (currentDialog && !currentDialog.isComplete) { if (mouseX > currentDialog.boxX && mouseX < currentDialog.boxX + currentDialog.boxWidth && mouseY > currentDialog.boxY && mouseY < currentDialog.boxY + currentDialog.boxHeight) { currentDialog.advanceDialog(); } else if (mouseX > backButton.x && mouseX < backButton.x + backButton.width && mouseY > backButton.y && mouseY < backButton.y + backButton.height) { goBackToStart(); // Go back if back button is clicked } } else { if (mouseX > backButton.x && mouseX < backButton.x + backButton.width && mouseY > backButton.y && mouseY < backButton.y + backButton.height) { goBackToStart(); // Go back if back button is clicked } } break; case 'howToPlay': if (mouseX > backButton.x && mouseX < backButton.x + backButton.width && mouseY > backButton.y && mouseY < backButton.y + backButton.height) { goBackToStart(); // Go back to start screen } break; } } class Character { constructor(key, displayName, imagePath) { this.key = key; this.displayName = displayName; this.imagePath = imagePath; this.img = null; } preload() { this.img = loadImage(this.imagePath); } } class Dialog { constructor(dialogLines) { // dialogLines: array of strings like 'sister_normal: "Hey!"' or just dialog text this.lines = dialogLines.map(lineObj => { if (typeof lineObj === 'string') { let match = lineObj.match(/^(\w+):\s*"([\s\S]*)"$/); if (match) { return { speaker: match[1], line: match[2] }; } else { return { speaker: 'sister_normal', line: lineObj }; } } else if (typeof lineObj === 'object') { return { speaker: lineObj.speaker || 'sister_normal', line: lineObj.line || '', sound: lineObj.sound || null }; } }); this.currentLineIndex = 0; this.isComplete = false; this.boxWidth = width * 0.8; this.boxHeight = 100; this.boxX = (width - this.boxWidth) / 2; this.boxY = height - this.boxHeight - 20; this.textSize = 18; this.textMargin = 15; this.lineHeight = this.textSize * 1.2; } display() { if (this.isComplete) { return; } fill(0, 0, 0, 200); rect(this.boxX, this.boxY, this.boxWidth, this.boxHeight, 10); let currentLineObj = this.lines[this.currentLineIndex]; let charKey = currentLineObj.speaker || 'sister_normal'; let displayedCharKey = charKey; // Either 'me' or one of the sister variants let charObj = characters[displayedCharKey] || characters['sister_normal']; let charImgSize = (displayedCharKey === 'me') ? 150 : 220; if (charObj && charObj.img) { let centerX = width / 2 - charImgSize / 2; let centerY = (displayedCharKey === 'me') ? height / 2 - charImgSize / 2 - 20 : height / 2 - charImgSize / 2 - 45; image(charObj.img, centerX, centerY, charImgSize, charImgSize); fill(255); textSize(18); textAlign(CENTER, TOP); text(charObj.displayName, width / 2, centerY + charImgSize + 5); } fill(255); textSize(this.textSize); textAlign(LEFT, TOP); let textY = this.boxY + this.textMargin; text(currentLineObj.line, this.boxX + this.textMargin, textY, this.boxWidth - this.textMargin * 2); if (this.currentLineIndex < this.lines.length - 1) { fill(255, 150); textSize(14); textAlign(RIGHT, BOTTOM); text("Click to continue...", this.boxX + this.boxWidth - this.textMargin, this.boxY + this.boxHeight - 5); } } advanceDialog() { if (this.isComplete) { return; } this.currentLineIndex++; if (this.currentLineIndex >= this.lines.length) { this.isComplete = true; console.log("Dialog complete!"); return; } let currentLineObj = this.lines[this.currentLineIndex]; if (currentLineObj.sound && dialogSounds[currentLineObj.sound]) { dialogSounds[currentLineObj.sound].play(); } } } function drawGameOverScreen() { push(); fill(0, 0, 0, 180); // dark overlay with 70% opacity rect(0, 0, width, height); let endingImg; if (gameEndingType === 'good') { endingImg = happyEndingImg; } else { endingImg = badEndingImg; } let imgWidth = 300; let imgHeight = 200; image(endingImg, width/2 - imgWidth/2, height/2 - 150, imgWidth, imgHeight); fill(255); textSize(40); textAlign(CENTER, CENTER); if (gameEndingType === 'good') { text("YOU WON!", width/2, height/2 - 30); } else { text("YOU LOST!", width/2, height/2 - 30); } textSize(24); text("Press R to restart", width/2, height/2 + 80); pop(); } function keyPressed() { // Check if 'R' key is pressed to restart the game when game over if (key === 'r' || key === 'R') { if (gameState === 'game' && gameOver) { resetGame(); } } } function resetGame() { hearts.count = 5; chipCounter.count = 10; gameOver = false; handsOverlap = false; rightHand.sisterTurned = false; rightHand.animationStarted = false; rightHand.currentImg = 1; rightHand.heartDeducted = false; }
5. Communication between Arduino and p5.js:
The Arduino and the p5.js sketch exchange data over a 9600 baud serial link, as we did in the sample homework 2 weeks back. On each loop, the Arduino reads the joystick X‐axis (A0), the pulse sensor (A1) and the select button (pin 8), then emits a line of the form “DIRX:<–1|0|1> PULSE:<0–1023> SEL:<0|1>
” (these were the specific format that I chose as it helped me debug as well). The p5.js sketch listens for these lines, parses out the numeric values for direction, heart rate, and button state, and uses them to drive menu selection, character motion, and game logic. When the sketch determines that the physical LED indicators should change color (based on the game state), it sends right back a single‐word command: “RED\n”, “YELLOW\n” or “GREEN\n” via writeSerial(), and the Arduino responds by calling applyLeds() to set the appropriate digital outputs. I’m glad that I was able to implement a bidirectional serial communication where not only the Arduino sends information to the p5 sketch but also vice versa.
Reflection
I was really proud of how seamlessly the physical box integrated into the game environment; it didn’t feel tacked on, but like a natural extension of the game world I created . I’m also proud that I soldered the LEDs to the wires because I was really afraid of soldering in the beginning. However, after a couple of tries on a sample, I was able to solder all 12 legs . In the future, I’d look to add a custom cursor that follows the joystick movements and actually wire up the Y-axis input so users can move that cursor freely around the screen. During playtesting, at least two people instinctively pushed the stick up and down—and, of course, nothing happened (since I hadn’t implemented Y at all)—which led to a couple of awkward pauses. Enabling full two-axis control would make the interaction more intuitive and keep the game flow smooth.
THANK YOU PROFESSOR & EVERYONE FOR AN AMAZING CLASS FOR MY LAST SEMESTER!