Video:
Drive links:https://drive.google.com/file/d/1tj29Zt4eafPmq3sbn2XQxWIdDe19ptf9/view?usp=sharing
https://drive.google.com/file/d/1iaTtnn3k2h35bS9jtLQnl48PngWvzTUW/view?usp=sharing
User Testing Documentation for the Project
To evaluate the user experience of the game, the following steps were conducted:
Participants: Two users were asked to play the game without prior instructions.
Environment: Each participant was given access to the joystick and mouse, along with the visual display of the game.
Recording: Gameplay sessions were recorded, capturing both screen activity and user interactions with the joystick and mouse.
Feedback: After the session, participants were asked to share their thoughts on the experience, including points of confusion and enjoyment.
Observations from User Testing
Most users instinctively tried to use the joystick to control the player.
Mapping joystick movement to player control was understood quickly.
Dying when hitting the wall was unexpected for both players, but they learned to avoid the walls and play more carefully quickly.
The dual control option (mouse click and joystick button) for starting the game worked well.
Powerups:
Participants found the power-up visuals engaging and intuitive.
Some users struggled to understand the effects of power-ups initially (e.g., what happens when picking up a turtle, or a lightning bolt)
But once they passed through the powerups they understood the effects it had.
Game Objectives:
The goal (reaching the endpoint) was clear to all participants.
Participants appreciated the timer and “Lowest Time” feature as a challenge metric.
What Worked Well
Joystick Integration: Smooth player movement with joystick controls was highly praised.
Visual Feedback: Power-up icons and heart-based life indicators were intuitive.
Engagement: Participants were motivated by the timer and the ability to beat their lowest time.
Obstacle Design: The maze structure was well-received for its balance of challenge and simplicity.
Areas for Improvement:
Power-up Explanation:
Players were unclear about the effects of power-ups until they experienced them.
I think this part does not need changing as it adds to the puzzling aspect of the game and makes further playthroughs more fun.
Collision Feedback:
When colliding with walls or losing a life, the feedback was clear as they could hear the sound effect and can see the heart lost at the top of the screen.
Lessons Learned
Need for Minimal Guidance: I like the challenge aspect of playing the game for the first time, with the lack of instructions, players are inspired to explore which increases their intrigue in the game.
Engaging Visuals and Sounds: Participants valued intuitive design elements like heart indicators and unique power-up icons.
Changes Implemented Based on Feedback
The speed was decreased slightly as the high speed was leading to many accidental deaths, The volume for the death feedback was increased to more clearly indicate what happens when a player consumes a death powerup or collide with a wall.
GAME:
Concept
The project is an interactive maze game that integrates an Arduino joystick controller to navigate a player through obstacles while collecting or avoiding power-ups. The objective is to reach the endpoint in the shortest possible time, with features like power-ups that alter gameplay dynamics (speed boosts, slowdowns, life deductions) and a life-tracking system with visual feedback.
- Player Movement: Controlled via the Arduino joystick.
- Game Start/Restart: Triggered by a joystick button press or mouse click.
- Power-Ups: Randomly spawned collectibles that provide advantages or challenges.
- Objective: Navigate the maze, avoid obstacles, and reach the goal with the least possible time.
The game is implemented using p5.js for rendering visuals and managing game logic, while Arduino provides the physical joystick interface. Serial communication bridges the joystick inputs with the browser-based game.
Design
Joystick Input:
X and Y axes: Control player movement.
Button press: Start or restart the game.
Visuals:
Player represented as a black circle.
Heart icons track lives.
Power-ups visually distinct ( icon-based).
Feedback:
Life loss triggers sound effects and visual feedback.
Timer displays elapsed and lowest times.
Game-over and win screens provide clear prompts.
Arduino Code:
const int buttonPin = 7; // The pin connected to the joystick button
int buttonState = HIGH; // Assume button is not pressed initially
void setup() {
Serial.begin(9600); // Start serial communication
pinMode(buttonPin, INPUT_PULLUP); // Set the button pin as input with pull-up resistor
}
void loop() {
int xPos = analogRead(A0); // Joystick X-axis
int yPos = analogRead(A1); // Joystick Y-axis
buttonState = digitalRead(buttonPin); // Read the button state
// Map analog readings (0-1023) to a more usable range if needed
int mappedX = map(xPos, 0, 1023, 0, 1000); // Normalize to 0-1000
int mappedY = map(yPos, 0, 1023, 0, 1000); // Normalize to 0-1000
// Send joystick values and button state as CSV (e.g., "500,750,1")
Serial.print(mappedX);
Serial.print(",");
Serial.print(mappedY);
Serial.print(",");
Serial.println(buttonState);
delay(50); // Adjust delay for data sending frequency
}
The circuit connects the joystick to the Arduino and includes connections for the button and power LEDs (to indicate remaining lives).
- Joystick:
- X-axis: A0
- Y-axis: A1
- Click (SW) connected to digital pin 7.
- VCC and GND to power the joystick module.
The p5.js sketch renders the maze, player, and power-ups, while handling game logic and serial communication.
Key features:
- Player Class: Handles movement, collision detection, and rendering.
- Power-Up Class: Manages random spawning, effects, and rendering.
- Obstacles Class: Generates Obstacles, and handles design aspects of them
- Joystick Input Handling: Updates player movement based on Arduino input.
- Game Loops: Includes logic for starting, restarting, and completing the game.
Code:
let player; //player variable
let obstacles = []; //list of obstacles
const OBSTACLE_THICKNESS = 18; //thickness of each rectangle
let rectImg, startImg; //maze pattern and start screen
let obstaclesG; // pre rendered obstacle course pattern for performance
let gameStarted = false; //game started flag
let gameEnded = false; //game ended flag
let startTime = 0; //start time
let elapsedTime = 0; //time passed since start of level
let lowestTime = Infinity; //infinity so the first level completion leads to the new lowest time
let lives = 3; // player starts with 3 lives
let collisionCooldown = false; // Tracks if cooldown is active
let cooldownDuration = 1000; // Cooldown duration in milliseconds
let lastCollisionTime = 0; // Timestamp of the last collision
let heartImg;//live hearts img
let bgMusic;
let lifeLostSound;
let winSound;
let serial; //for arduino connection
let joystickX = 500; // default joystick X position
let joystickY = 500; // default joystick Y position
let powerUps = []; // Array to store power-ups
let powerUpSpawnInterval = 10000; // interval to spawn a new
let lastPowerUpTime = 0; // time when the last power-up was spawned
let speedUpImg, slowDownImg, loseLifeImg;
let buttonPressed = false;
function preload() {
rectImg = loadImage('pattern.png'); // Load obstacle pattern
startImg = loadImage('start.png'); // Load start screen image
heartImg = loadImage('heart.png');// load heart image
bgMusic = loadSound('background_music.mp3'); // background music
lifeLostSound = loadSound('life_lost.wav'); // Sound for losing a life
winSound = loadSound('win_sound.wav'); //sound for winning
speedUpImg = loadImage('speed_up.png'); //icons for powerups
slowDownImg = loadImage('slow_down.png');
loseLifeImg = loadImage('lose_life.png');
}
function setup() {
createCanvas(1450, 900);
serial = new p5.SerialPort(); // Initialize SerialPort
serial.open('/dev/tty.usbmodem1101'); //the code for the arduino device being opened
serial.on('data', handleSerialData);
player = new Player(30, 220, 15, 5); //maze starting coordinate for player
//maze background
obstaclesG = createGraphics(1450, 900);
obstaclesG.background(220);
// Add obstacles
addObstacles(); //adds all the obstacles during setup
// loops through the list and displays each one
for (let obs of obstacles) {
obs.showOnGraphics(obstaclesG);
}
bgMusic.loop() //background music starts
}
function spawnPowerUp() {
let x, y;
let validPosition = false;
while (!validPosition) {
x = random(50, width - 50);
y = random(50, height - 50);
//a valid position for a powerup is such that it does not collide with any obstacles
validPosition = !obstacles.some(obs =>
collideRectCircle(obs.x, obs.y, obs.w, obs.h, x, y, 30)
) && !powerUps.some(pu => dist(pu.x, pu.y, x, y) < 60);
}
const types = ["speedUp", "slowDown", "loseLife"];
const type = random(types); //one random type of powerup
powerUps.push(new PowerUp(x, y, type)); //adds to powerup array
}
function handlePowerUps() {
// Spawn a new power-up if the interval has passed
if (millis() - lastPowerUpTime > powerUpSpawnInterval) {
spawnPowerUp();
lastPowerUpTime = millis(); // reset the spawn timer
}
// Display and check for player interaction with power-ups
for (let i = powerUps.length - 1; i >= 0; i--) {
const powerUp = powerUps[i];
powerUp.display();
if (powerUp.collidesWith(player)) {
powerUp.applyEffect(); // Apply the effect of the power-up
powerUps.splice(i, 1); // Remove the collected power-up
}
}
}
function draw() {
if (!gameStarted) {
background(220);
image(startImg, 0, 0, width, height);
noFill();
stroke(0);
// Start the game with joystick button or mouse click
if (buttonPressed || (mouseIsPressed && mouseX > 525 && mouseX < 915 && mouseY > 250 && mouseY < 480)) {
gameStarted = true;
startTime = millis();
}
} else if (!gameEnded) {
background(220);
image(obstaclesG, 0, 0);
player.update(obstacles); // Update player position
handlePowerUps(); // Manage power-ups
player.show(); // Display the player
// Update and display elapsed time, hearts, etc.
elapsedTime = millis() - startTime;
serial.write(`L${lives}\n`);
displayHearts();
fill(0);
textSize(22);
textAlign(LEFT);
text(`Time: ${(elapsedTime / 1000).toFixed(2)} seconds`, 350, 50);
textAlign(RIGHT);
text(
`Lowest Time: ${lowestTime < Infinity ? (lowestTime / 1000).toFixed(2) : "N/A"}`,
width - 205,
50
);
if (dist(player.x, player.y, 1440, 674) < player.r) {
endGame(); // Check if the player reaches the goal
}
} else if (gameEnded) {
// Restart the game with joystick button or mouse click
if (buttonPressed || mouseIsPressed) {
restartGame();
}
}
}
function handleSerialData() {
let data = serial.readLine().trim(); // Read and trim incoming data
if (data.length > 0) {
let values = data.split(","); // Split data by comma
if (values.length === 3) {
joystickX = Number(values[0]); // Update joystick X
joystickY = Number(values[1]); // Update joystick Y
buttonPressed = Number(values[2]) === 0; // Update button state (0 = pressed)
}
}
}
function displayHearts() { //display lives
const heartSize = 40; // size of each heart
const startX = 650; // x position for hearts
const startY = 40; // y position for hearts
for (let i = 0; i < lives; i++) { //only displays as many hearts as there are lives left
image(heartImg, startX + i * (heartSize + 10), startY, heartSize, heartSize);
}
}
function endGame() {
gameEnded = true;
noLoop(); // stop the draw loop
winSound.play(); //if game ends
serial.write("END\n");
// check if the current elapsed time is a new record
const isNewRecord = elapsedTime < lowestTime;
if (isNewRecord) {
lowestTime = elapsedTime; // update lowest time
}
// Display end screen
background(220);
fill(0);
textSize(36);
textAlign(CENTER, CENTER);
text("Congratulations! You reached the goal!", width / 2, height / 2 - 100);
textSize(24);
text(`Time: ${(elapsedTime / 1000).toFixed(2)} seconds`, width / 2, height / 2 - 50);
// Display "New Record!" message if applicable
if (isNewRecord) {
textSize(28);
fill(255, 0, 0); // Red color for emphasis
text("New Record!", width / 2, height / 2 - 150);
}
textSize(24);
fill(0); // Reset text color
text("Click anywhere to restart", width / 2, height / 2 + 50);
}
function mouseClicked() {
if (!gameStarted) {
// start the game if clicked in start button area
if (mouseX > 525 && mouseX < 915 && mouseY > 250 && mouseY < 480) {
gameStarted = true;
startTime = millis();
}
} else if (gameEnded) {
// Restart game
restartGame();
}
}
function checkJoystickClick() {
if (buttonPressed) {
if (!gameStarted) {
gameStarted = true;
startTime = millis();
} else if (gameEnded) {
restartGame();
}
}
}
function restartGame() {
gameStarted = true;
gameEnded = false;
lives = 3;
powerUps = []; // Clear all power-ups
player = new Player(30, 220, 15, 5); // Reset player position
startTime = millis(); // Reset start time
loop();
bgMusic.loop(); // Restart background music
}
function loseGame() {
gameEnded = true; // End the game
noLoop(); // Stop the draw loop
bgMusic.stop();
serial.write("END\n");
// Display level lost message
background(220);
fill(0);
textSize(36);
textAlign(CENTER, CENTER);
text("Level Lost!", width / 2, height / 2 - 100);
textSize(24);
text("You ran out of lives!", width / 2, height / 2 - 50);
text("Click anywhere to restart", width / 2, height / 2 + 50);
}
function keyPressed() { //key controls
let k = key.toLowerCase();
if (k === 'w') player.moveUp(true);
if (k === 'a') player.moveLeft(true);
if (k === 's') player.moveDown(true);
if (k === 'd') player.moveRight(true);
if (k === 'f') fullscreen(!fullscreen());
}
function keyReleased() { //to stop movement once key is released
let k = key.toLowerCase();
if (k === 'w') player.moveUp(false);
if (k === 'a') player.moveLeft(false);
if (k === 's') player.moveDown(false);
if (k === 'd') player.moveRight(false);
}
class Player { //player class
constructor(x, y, r, speed) {
this.x = x;
this.y = y;
this.r = r;
this.speed = speed;
this.movingUp = false;
this.movingDown = false;
this.movingLeft = false;
this.movingRight = false;
}
update(obsArray) { //update function
let oldX = this.x;
let oldY = this.y;
//joystick-based movement
if (joystickX < 400) this.x -= this.speed; // move left
if (joystickX > 600) this.x += this.speed; // move right
if (joystickY < 400) this.y -= this.speed; // move up
if (joystickY > 600) this.y += this.speed; // move down
// constrain to canvas
this.x = constrain(this.x, this.r, width - this.r);
this.y = constrain(this.y, this.r, height - this.r);
// restrict movement if colliding with obstacles
if (this.collidesWithObstacles(obsArray)) {
this.x = oldX; // revert to previous position x and y
this.y = oldY;
// Handle life deduction only if not in cooldown to prevent all lives being lost in quick succession
if (!collisionCooldown) {
lives--;
lastCollisionTime = millis(); // record the time of this collision
collisionCooldown = true; // activate cooldown
lifeLostSound.play(); // play life lost sound
if (lives <= 0) {
loseGame(); // Call loseGame function if lives reach 0
}
}
}
// Check if cooldown period has elapsed
if (collisionCooldown && millis() - lastCollisionTime > cooldownDuration) {
collisionCooldown = false; // reset cooldown
}
}
show() { //display function
fill(0);
ellipse(this.x, this.y, this.r * 2);
}
collidesWithObstacles(obsArray) { //checks collisions in a loop
for (let obs of obsArray) {
if (this.collidesWithRect(obs.x, obs.y, obs.w, obs.h)) return true;
}
return false;
}
collidesWithRect(rx, ry, rw, rh) { //collision detection function checks if distance between player and wall is less than player radius which means a collision occurred
let closestX = constrain(this.x, rx, rx + rw);
let closestY = constrain(this.y, ry, ry + rh);
let distX = this.x - closestX;
let distY = this.y - closestY;
return sqrt(distX ** 2 + distY ** 2) < this.r;
}
moveUp(state) {
this.movingUp = state;
}
moveDown(state) {
this.movingDown = state;
}
moveLeft(state) {
this.movingLeft = state;
}
moveRight(state) {
this.movingRight = state;
}
}
class Obstacle { //obstacle class
constructor(x, y, length, horizontal) {
this.x = x;
this.y = y;
this.w = horizontal ? length : OBSTACLE_THICKNESS;
this.h = horizontal ? OBSTACLE_THICKNESS : length;
}
showOnGraphics(pg) { //to show the obstacle pattern image repeatedly
for (let xPos = this.x; xPos < this.x + this.w; xPos += rectImg.width) {
for (let yPos = this.y; yPos < this.y + this.h; yPos += rectImg.height) {
pg.image(
rectImg,
xPos,
yPos,
min(rectImg.width, this.x + this.w - xPos),
min(rectImg.height, this.y + this.h - yPos)
);
}
}
}
}
class PowerUp {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type; // Type of power-up: 'speedUp', 'slowDown', 'loseLife'
this.size = 30; // Size of the power-up image
}
display() {
let imgToDisplay;
if (this.type === "speedUp") imgToDisplay = speedUpImg;
else if (this.type === "slowDown") imgToDisplay = slowDownImg;
else if (this.type === "loseLife") imgToDisplay = loseLifeImg;
image(imgToDisplay, this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);
}
collidesWith(player) {
return dist(this.x, this.y, player.x, player.y) < player.r + this.size / 2;
}
applyEffect() {
if (this.type === "speedUp") player.speed += 2;
else if (this.type === "slowDown") player.speed = max(player.speed - 1, 2);
else if (this.type === "loseLife") {
lives--;
lifeLostSound.play();
if (lives <= 0) loseGame();
}
}
}
function addObstacles() {
// adding all obstacles so the collision can check all in an array
obstacles.push(new Obstacle(0, 0, 1500, true));
obstacles.push(new Obstacle(0, 0, 200, false));
obstacles.push(new Obstacle(0, 250, 600, false));
obstacles.push(new Obstacle(1432, 0, 660, false));
obstacles.push(new Obstacle(1432, 700, 200, false));
obstacles.push(new Obstacle(0, 882, 1500, true));
obstacles.push(new Obstacle(100, 0, 280, false));
obstacles.push(new Obstacle(0, 400, 200, true));
obstacles.push(new Obstacle(200, 90, 328, false));
obstacles.push(new Obstacle(300, 0, 500, false));
obstacles.push(new Obstacle(120, 500, 198, true));
obstacles.push(new Obstacle(0, 590, 220, true));
obstacles.push(new Obstacle(300, 595, 350, false));
obstacles.push(new Obstacle(100, 680, 200, true));
obstacles.push(new Obstacle(0, 770, 220, true));
obstacles.push(new Obstacle(318, 400, 250, true));
obstacles.push(new Obstacle(300, 592, 250, true));
obstacles.push(new Obstacle(420, 510, 85, false));
obstacles.push(new Obstacle(567, 400, 100, false));
obstacles.push(new Obstacle(420, 680, 100, false));
obstacles.push(new Obstacle(567, 750, 150, false));
obstacles.push(new Obstacle(420, 680, 400, true));
obstacles.push(new Obstacle(410, 90, 200, false));
obstacles.push(new Obstacle(410, 90, 110, true));
obstacles.push(new Obstacle(520, 90, 120, false));
obstacles.push(new Obstacle(410, 290, 350, true));
obstacles.push(new Obstacle(660, 90, 710, false));
obstacles.push(new Obstacle(660, 90, 100, true));
obstacles.push(new Obstacle(420, 680, 500, true));
obstacles.push(new Obstacle(410, 290, 315, true));
obstacles.push(new Obstacle(830, 0, 290, false));
obstacles.push(new Obstacle(760, 200, 70, true));
obstacles.push(new Obstacle(742, 200, 90, false));
obstacles.push(new Obstacle(950, 120, 480, false));
obstacles.push(new Obstacle(1050, 0, 200, false));
obstacles.push(new Obstacle(1150, 120, 200, false));
obstacles.push(new Obstacle(1250, 0, 200, false));
obstacles.push(new Obstacle(1350, 120, 200, false));
obstacles.push(new Obstacle(1058, 310, 310, true));
obstacles.push(new Obstacle(760, 390, 300, true));
obstacles.push(new Obstacle(660, 490, 200, true));
obstacles.push(new Obstacle(760, 582, 200, true));
obstacles.push(new Obstacle(920, 680, 130, false));
obstacles.push(new Obstacle(1040, 310, 650, false));
obstacles.push(new Obstacle(790, 760, 200, false));
obstacles.push(new Obstacle(1150, 400, 400, false));
obstacles.push(new Obstacle(1160, 560, 300, true));
obstacles.push(new Obstacle(1325, 440, 200, false));
obstacles.push(new Obstacle(1240, 325, 150, false));
obstacles.push(new Obstacle(1150, 800, 200, true));
obstacles.push(new Obstacle(1432, 850, 130, false));
obstacles.push(new Obstacle(1240, 720, 200, true));
}
What I’m Proud Of
Joystick Integration: Seamless control with physical inputs enhances immersion.
Dynamic Power-Ups: Randomized, interactive power-ups add a strategic layer.
Visual and Auditory Feedback: Engaging effects create a polished gaming experience.
Robust Collision System: Accurate handling of obstacles and player interaction.
Areas for Improvement:
- Tutorial/Instructions: Add an in-game tutorial to help new users understand power-ups and controls. This could be a simple maze with all powerups and a wall to check collision.
- Level Design: Introduce multiple maze levels with increasing difficulty.
- Enhanced Feedback: Add animations for power-up collection and collisions
Conclusion:
I had a lot of fun working on this project, it was a fun experience learning serial communication and especially integrating all the powerup logic. I think with some polishing and more features this could be a project that I could publish one day.