HAILSTORM HAIVOC
My game concept simulates driving safely through a hailstorm in AbuDhabi. As a player, you control a toy car, displayed on the screen, by physically moving it left or right to dodge falling hailstones. This game is designed to reflect the unpredictable nature of real hailstorms and incorporates real-time physical interaction through a toy car and Arduino sensors, offering a unique and engaging gameplay experience, also symbolizing the hailstorm in Abu Dhabi months ago.
Implementation Overview
I built the game using p5.js for the visual components and game logic, and Arduino for the physical interaction aspects. The Arduino setup uses a pushbutton to start or restart the game and an HC-SRO ultrasonic sensor to determine the position of the toy car, which translates into the car’s movement on the screen. These inputs are sent to the p5.js application through serial communication, allowing the player’s physical movements to directly influence the gameplay.
Interaction Design Description
The interaction design focuses on tangible interaction, where the physical movement of the toy car (left or right) translates to the movement of the car on the screen. This method fosters more engaging and intuitive gameplay. An arcade button connected to the Arduino allows players to start or restart the game easily, making the interface user-friendly and accessible.
Arduino Code Explanation
In my Arduino code, I manage inputs from the ultrasonic sensor and the button. The ultrasonic sensor measures the distance of an object (the toy car) from the sensor, and this measurement is used to control the car’s position on the p5.js screen. The button input is debounced to avoid processing multiple unintended signals, used to start or restart the game, and toggles an LED for visual feedback. Serial communication sends the button press count and distance measurement to the p5.js application.
p5.js Code Explanation
My p5.js code is responsible for creating the visual representation of the game—rendering the car, hailstones, and other visual elements on the screen. It also handles the game logic, such as detecting collisions between the car and hailstones, updating the game state based on Arduino inputs, and managing game timers and scores.
The game has 3 screens:
The Main menu where the player is told the directions to play, the winning screen which comes up after 10 seconds of the user playing, and the game over screen which pops up if the car collides with the hailstones.
Communication Between Arduino and p5.js
I achieve communication between the Arduino and p5.js through serial communication. The Arduino continuously sends data from the button and the ultrasonic sensor to the p5.js application, which reads this data to update the game state accordingly. The p5.js listens for serial data, parses it, and uses these inputs to control the car’s movements and manage game controls like start and restart.
Important Images
Aspects I’m Particularly Proud Of
I am proud of several key accomplishments in this project:
-
- Successfully integrating physical components with a digital interface, which enhanced the interactive gaming experience.
- Overcoming the challenges associated with serial communication between Arduino and p5.js, a complex aspect of hardware-software integration.
- Completing the project within a limited timeframe and being able to innovate with a unique approach to game design and interaction.
- As well as my setup with the street and box; I really enjoyed making the box and the street, adding an extra layer of creativity
Future Improvement Areas
For future enhancements, I could consider:
-
- Implementing sound and music for the game, I initially had music in the game but I decided to remove it, since the exhibition is already quite chaotic and the music won’t be heard
- Enhancing the game’s visual and sound effects to create a more immersive experience.
- Implementing additional gameplay features, such as different levels of difficulty or various weather conditions affecting gameplay.
- Exploring different sensors or refining the calibration of the HC-SRO4 sensor. As it was slightly glitchy at first, but I managed to fix it.
Here is my Arduino code:
const int trigPin = 9; const int echoPin = 10; // Arduino code for button, which detects the counts const int buttonPin = 2; // the number of the pushbutton pin const int ledPin = 3; // the number of the LED pin // variables will change: int buttonState = 0; // variable for reading the pushbutton status int lastButtonState = HIGH; // variable for reading the last pushbutton status unsigned long lastDebounceTime = 0; // the last time the output pin was toggled unsigned long debounceDelay = 50; // the debounce time; increase if the output flickers int pressCount = 0; // count of button presses void setup() { pinMode(trigPin, OUTPUT); pinMode(echoPin, INPUT); pinMode(ledPin, OUTPUT); // initialize the LED pin as an output pinMode(buttonPin, INPUT_PULLUP); // initialize the pushbutton pin as an input with internal pull-up resistor Serial.begin(9600); } void loop() { float distance = getDistanceCm(); // Get the distance in cm int reading = digitalRead(buttonPin); // check if the button state has changed from the last reading if (reading != lastButtonState) { // reset the debouncing timer lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > debounceDelay) { // if the button state has changed: if (reading != buttonState) { buttonState = reading; // only toggle the LED if the new button state is LOW if (buttonState == LOW) { digitalWrite(ledPin, HIGH); pressCount++; // increment the press count } else { digitalWrite(ledPin, LOW); } } } // save the reading. Next time through the loop, it will be the lastButtonState: lastButtonState = reading; Serial.print(pressCount); // print the count to the serial monitor Serial.print(","); Serial.println(distance); // Print the distance to the Serial monitor delay(100); // Short delay before next measurement } float getDistanceCm() { // Trigger the measurement digitalWrite(trigPin, LOW); delayMicroseconds(2); digitalWrite(trigPin, HIGH); delayMicroseconds(10); digitalWrite(trigPin, LOW); // Calculate the distance based on the time of echo float duration = pulseIn(echoPin, HIGH); float distance = (duration * 0.0343) / 2; return distance; }
And this is the code from P5:
//Add this in index.html // <!-- Load the web-serial library --> // <script src="p5.web-serial.js"></script> // Go download the web serial at https://github.com/Pi-31415/Intro-To-IM/blob/main/p5.web-serial.js // Declare a variable to hold the smoothed value let smoothedDistance = 0; let gameMode = 0; // Variable to store the current game mode var landscape; // Variable to store the landscape graphics var car_diameter = 15; // Diameter of the ball var bomb_diameter = 10; // Diameter of the bombs var cardistancex; var ypoint; var zapperwidth = 6; // Width of the zapper var numofbombs = 3; // Number of bombs var bombposX = []; // Array to store X positions of bombs var bombposY = []; // Array to store Y positions of bombs var bombacceleration = []; // Array to store acceleration of each bomb var bombvelocity = []; // Array to store velocity of each bomb var time = 0; // Variable to track time, usage context not provided var timeperiod = 0; // Variable to store a time period, usage not clear without further context //var score = 0; // Variable to store the current score var posX; // X position, usage context not provided var inMainMenu = true; // Boolean to check if the game is in the main menu //var prevScore = 0; // Variable to store the previous score let font; // Variable to store font, usage context not provided let introgif; let gameovergif; let gif3; let survivedgif; let countdownTimer = 10; // Countdown timer starting from 30 seconds let serial; // Declare a serial port object let latestData = "waiting for data"; // Latest data received let gameovernow = false; //CONNECTION let clickCount = 0; let previousClickCount = 0; // Store the previous click count let distanceReal = 255; let ignorefirstclick = false; let gameovergifLarge; function preload() { introgif = createImg( "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExNTA4MG42MWlhdWV3Y2cyZ3U1cTFqZHhpbHp1amcweDhjYzhkcHBkYyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/35cSlj5ELlzGSg0ZYM/giphy.gif" ); introgif.hide(); survivedgif = createImg( "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExdm1yeWt3aGlteWZkcHB3czk3Ym81YWtrZTVtb29pMng2NW83bnF4bCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LY3JbLuhmjDVfCcCxh/giphy.gif" ); survivedgif.hide(); gameovergif = createImg( "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExZHo1dmNrdzQ5NnYycWdvMjBqOGt1Zmg0MTdxZHQ4eHAyZGZrMDZtbCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/hp9wzCTbGeGfIkXE6a/giphy.gif" ); gameovergif.hide(); gameovergifLarge = createImg("https://intro-to-im.vercel.app/afra/gameoer.gif"); gameovergifLarge.hide(); //Temp gif3 = loadImage("https://intro-to-im.vercel.app/afra/bggif.gif"); font = loadFont("fonts/Inconsolata_Condensed-Light.ttf"); car = loadImage("car.png"); car2 = loadImage("car2.png"); } // Cloud class starts class Cloud { constructor(x, y, speed, stepSpeed, scale) { this.x = x; this.y = y; this.scale = scale; // Add scale property this.speed = speed; this.stepSpeed = stepSpeed; this.step = 0; this.facingRight = false; // Initially moving to the left this.animationTimer = null; } move() { if (this.facingRight) { this.x += this.speed; } } display() { push(); if (!this.facingRight) { scale(-this.scale, this.scale); // Apply scale with horizontal flip image(oneDimensionarray[this.step], -this.x, this.y); } else { scale(this.scale, this.scale); // Apply scale image(oneDimensionarray[this.step], this.x, this.y); } pop(); } advanceStep() { this.step = (this.step + 1) % 8; } startAnimation() { this.facingRight = true; clearInterval(this.animationTimer); this.animationTimer = setInterval(() => this.advanceStep(), this.stepSpeed); } stopAnimation() { this.facingRight = false; clearInterval(this.animationTimer); } } let clouds = []; // Cloud class ends // Define a maximum boundary for the distance const maxDistance = 600; // Set this to whatever maximum value makes sense in your context function mapDistance(distanceReal) { // Define the smoothing factor (alpha). Smaller values make the motion smoother but less responsive. const alpha = 0.2; // Calculate the target position without smoothing const targetPosition = (640 * (distanceReal - 3)) / 17; // Apply exponential smoothing smoothedDistance = alpha * targetPosition + (1 - alpha) * smoothedDistance; // Ensure the smoothed distance does not exceed the maximum allowed distance if (smoothedDistance > maxDistance) { smoothedDistance = maxDistance; } return smoothedDistance; } function setup() { createCanvas(640, 480); textAlign(CENTER); gif3.resize(640 * 2, 480 * 2); var temp00 = 0, temp01 = -20; // A while loop that increments temp01 based on temp00 until temp01 is less than the canvas height while (temp01 < height) { temp00 += 0.02; // Increment temp00 by 0.02 in each loop iteration temp01 += temp00; // Increment temp01 by the current value of temp00 timeperiod++; // Increment timeperiod in each iteration } // Calculate the initial position of posX based on zapperwidth and car_diameter posX = zapperwidth + 0.5 * car_diameter - 2; // Set cardistancex and ypoint relative to the width and height of the canvas cardistancex = 0.7 * width; // Set cardistancex to 70% of the canvas width ypoint = height - 0.5 * car_diameter + 1; // Set ypoint based on the canvas height and car_diameter initbombpos(); // Call the initbombpos function (presumably initializes bomb positions) imageMode(CENTER); // Set the image mode to CENTER for drawing images centered at coordinates // Initialize variables for width and height based on // Create 3 clouds with horizontal offsets, different speeds and scales clouds.push(new Cloud(width / 8, height / 9, 0, 100, 0.9)); // First cloud clouds.push(new Cloud((2 * width) / 5, height / 9, 0, 100, 1.2)); // Second cloud clouds.push(new Cloud((2 * width) / 2, height / 9, 0, 200, 1.0)); // Third cloud } //Serial Read function readSerial(data) { //////////////////////////////////// //READ FROM ARDUINO HERE //////////////////////////////////// if (data != null) { // make sure there is actually a message // split the message let fromArduino = split(trim(data), ","); // if the right length, then proceed if (fromArduino.length == 2) { // only store values here // do everything with those values in the main draw loop // We take the string we get from Arduino and explicitly // convert it to a number by using int() // e.g. "103" becomes 103 clickCount = int(fromArduino[0]); distanceReal = parseFloat(fromArduino[1]); } } } function draw() { clear(); //Establish Serial if (!serialActive) { } else { text("Connected", 20, 30); // Print the current values console.log("clickCount = " + str(clickCount), 20, 50); text("distanceReal = " + str(distanceReal), 20, 70); } // Check if clickCount has increased if (clickCount > previousClickCount) { if (ignorefirstclick) { simulateMouseClick(); // Call your function that simulates a mouse click } ignorefirstclick = true; } previousClickCount = clickCount; // Update previousClickCount // background(gif3); // displayTimer(); // updateTimer(); if (gameMode == 0) { //Main Menu textFont(font); textSize(50); // Larger text size for the game title textAlign(CENTER, CENTER); // Align text to be centered text("HAILSTORM HAVOC", width / 2, height / 2 - 40); textSize(16); // Smaller text size for the directions text( "DIRECTIONS:\n click mouse to dodge hail\n the longer the press the further right\n the car will go\n\n AVOID the red line - crossing it means game over", width / 2, height / 2 + 50 ); textSize(20); text("Click to start!", width / 2, height / 2 + 140); introgif.show(); introgif.position(0, 0); introgif.size(width, height); } else if (gameMode == 1) { //Actual game // gif3.show(); image(gif3, 20, 20); displayTimer(); updateTimer(); introgif.hide(); survivedgif.hide(); gameovergifLarge.hide(); // fill(239, 58, 38); // rect(0, 0, zapperwidth, height); //scoreUpdate(); fill(255); noStroke(); for (var i = 0; i < numofbombs; i++) { ellipse(bombposX[i], bombposY[i], bomb_diameter, bomb_diameter); } updatebombpos(); // ellipse(cardistancex, ypoint, car_diameter, car_diameter); //Betwen 0 and 640 let cardistancex = mapDistance(distanceReal); image(car, cardistancex, ypoint - 30, car_diameter * 5, car_diameter * 5); if (cardistancex <= posX || bombCollistonTest()) { //gameover(); // Call the gameover function if either condition is true gameMode = 3; } time += 1; // if (frameCount % 60 == 0) { // score++; // Increase score by 1 // } checkGameOver(); // gif3.show(); // gif3.position(0, 0); // gif3.size(width, height); } else if (gameMode == 2) { //Survive survivedgif.show(); survivedgif.position(0, 0); survivedgif.size(width, height); restartGame(); // displayWin(); } else if (gameMode ==3){ //GameOver gameovergifLarge.show(); gameovergifLarge.position(0, 0); gameovergifLarge.size(width, height); restartGame(); } } function displayTimer() { if (font) { textFont(font); // Set the loaded font for displaying text } fill(255, 255, 0); // Set the text color to white for visibility textSize(30); // Set the text size textAlign(RIGHT, TOP); // Align text to the center top textStyle(BOLD); text("Time: " + countdownTimer, width - 10, 10); // Display the timer on the canvas } function updateTimer() { if (frameCount % 60 == 0 && countdownTimer > 0) { countdownTimer--; // Decrease timer by 1 each second } } function updatebombpos() { // Iterate over each bomb for (var i = 0; i < numofbombs; i++) { bombvelocity[i] += bombacceleration[i]; // Update the velocity of the bomb by adding its acceleration bombposY[i] += bombvelocity[i]; // Update the Y position of the bomb based on its velocity } if (time > timeperiod) { initbombpos(); // Reinitialize the positions of the bombs by calling the initbombpos function time = 0; } } function initbombpos() { for (var i = 0; i < numofbombs; i++) { bombacceleration[i] = random(0.02, 0.03); // Randomize the acceleration bombvelocity[i] = random(0, 5); // Randomize the initial velocity bombposX[i] = random(zapperwidth + 0.5 * car_diameter, width); // Randomize the X position within playable area bombposY[i] = -bomb_diameter; // Start bombs just above the top of the canvas } } function bombCollistonTest() { // Define the car's bounding box let carLeft = cardistancex - car_diameter * 2.5; let carRight = cardistancex + car_diameter * 2.5; let carTop = ypoint - 20 - car_diameter * 2.5; let carBottom = ypoint - 20 + car_diameter * 2.5; // Iterate over each bomb to check for a collision for (var i = 0; i < numofbombs; i++) { // Check if bomb is within the bounding box of the car if ( bombposX[i] >= carLeft && bombposX[i] <= carRight && bombposY[i] >= carTop && bombposY[i] <= carBottom ) { return true; // Collision detected } } return false; // No collision } //This function checks for collisions between the player and each bomb by comparing the distance between them to a threshold. If any bomb is too close (within the threshold), it returns true (collision detected). Otherwise, it returns false. function gameover() { gameovernow = true; let cardistancex = mapDistance(distanceReal); image(car2, cardistancex, ypoint - 30, car_diameter * 5, car_diameter * 5); gameovergif.show(); gameovergif.position(0, 0); gameovergif.size(width, height); } function keyPressed() { if (key == "a") { // important to have in order to start the serial con nection!! setUpSerial(); } } function simulateMouseClick() { console.log("Mouse clicked via Arduino"); // Log or perform actions here // You can call any functions here that you would have called in mouseClicked() if (gameMode == 0 || gameMode == 2 || gameMode == 3) { gameMode = 1; } //just flipping between modes 0 and 1 clouds.forEach((cloud) => cloud.startAnimation()); } function mousePressed() { //No mouse press } function mouseReleased() { clouds.forEach((cloud) => cloud.stopAnimation()); } function checkGameOver() { if (countdownTimer <= 0) { gameMode = 2; } } function restartGame() { // Reset all game variables to their initial values gameovernow = false; gameovergif.hide(); time = 0; //score = 0; countdownTimer = 5; posX = zapperwidth + 0.5 * car_diameter - 2; cardistancex = 0.5 * width; ypoint = height - 0.5 * car_diameter + 1; initbombpos(); // Restart the game loop loop(); } //This function resets the game environment and variables to their initial state, essentially restarting the game. It resumes background music, pauses any game over , resets score and time, repositions the player and bombs, and restarts the game loop.
Overall, I’m very proud of my project and I was very happy to see users play my game in the exhibition.