Midterm Project

Link to the Sketch: https://editor.p5js.org/izza.t/full/V9Mv_WERI

For my midterm, I decided to do a spin-off of a classic maze that is also heavily inspired by 2 other things I love: mystery and cats. The story of the game involves an archeologist exploring an abandoned tomb that is said to hold a large treasure with their cat. Suddenly, something spooks the cats and it runs off into the tomb. It is then revealed that the tomb is like a maze, and the user must navigate through it. The catch? It’s pitch black apart from the small light from their flashlight and there are booby traps in certain parts of the maze like a mummy, beetles, and a snake that will cause the player to have to restart if the player collides into them.

The game works by showing a start screen that explains the situation and what keys to use to navigate the game. The player uses the arrow keys to navigate up, down, left, and right through the maze and can use the ‘C’ key to call for their cat and hear it’s meow which expands their flashlight radius momentarily (almost like a power up). This feature does have a cooldown feature that is displayed at the top. The user must navigate through the maze to the end to win and find both their cat and the lost treasure. They must do so without triggering any of the booby traps and dealing with all of the dead ends in the maze.

The maze itself took the longest time to create, almost an entire day, as it all had to be hardcoded using an image I had found of tomb-like stone walls online. Then, creating the flashlight like circle around the player and making sure that only pieces of the maze that are within that circle are displayed required the use of masking. This required me to use a new function called drawingContext which unlocks more features of the canvas in p5js and allowed me to do that. The code for which I’m very proud of and can be seen below.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Maze {
constructor() {
this.walls = [
// Outer boundaries (these are always visible)
{x: 75, y: 0, w: 1200, h: 30}, // Top
{x: 0, y: 570, w: 1200, h: 30}, // Bottom
{x: 0, y: 0, w: 30, h: 600}, // Left
{x: 970, y: 0, w: 30, h: 535}, // Right
// Interior walls (these are only visible within the flashlight radius)
{x: 75, y: 25, w: 10, h: 100},
{x: 75, y: 165, w: 10, h: 220},
{x: 75, y: 425, w: 10, h: 90},
{x: 145, y: 25, w: 10, h: 100},
{x: 195, y: 25, w: 10, h: 100},
{x: 195, y: 115, w: 75, h: 10},
{x: 75, y: 115, w: 75, h: 10},
{x: 75, y: 165, w: 200, h: 10},
{x: 265, y:115, w: 10, h: 55},
{x: 75, y: 375, w: 165, h: 10},
{x: 75, y: 425, w: 215, h: 10},
{x: 280, y: 295, w: 10, h: 140},
{x: 235, y: 355, w: 10, h: 30},
{x: 105, y: 345, w: 140, h: 10},
{x: 155, y: 295, w: 135, h: 10},
{x: 155, y: 255, w: 10, h: 50},
{x: 105, y: 205, w: 10, h: 150},
{x: 75, y: 515, w: 210, h: 10},
{x: 280, y: 515, w: 10, h: 60},
{x: 105, y: 490, w: 205, h: 10},
{x: 105, y: 450, w: 205, h: 10},
{x: 105, y: 450, w: 10, h: 50},
{x: 155, y: 255, w: 155, h: 10},
{x: 115, y: 205, w: 195, h: 10},
{x: 300, y:95, w: 10, h: 115},
{x: 235, y: 85, w: 75, h: 10},
{x: 300, y:255, w: 10, h: 205},
{x: 300, y:490, w: 10, h: 85},
{x: 225, y: 25, w: 10, h: 70},
{x: 345, y: 25, w: 10, h: 320},
{x: 345, y: 345, w: 225, h: 10},
{x: 345, y: 395, w: 60, h: 10},
{x: 465, y: 395, w: 60, h: 10},
{x: 345, y: 395, w: 10, h: 180},
{x: 515, y: 395, w: 10, h: 180},
{x: 565, y: 345, w: 10, h: 180},
{x: 565, y: 525, w: 50, h: 10},
{x: 605, y: 300, w: 10, h: 230},
{x: 655, y: 165, w: 10, h: 410},
{x: 405, y: 300, w: 205, h: 10},
{x: 405, y: 255, w: 205, h: 10},
{x: 405, y: 225, w: 205, h: 10},
{x: 605, y: 225, w: 10, h: 40},
{x: 405, y: 255, w: 10, h: 50},
{x: 405, y: 25, w: 10, h: 210},
{x: 465, y: 165, w: 195, h: 10},
{x: 465, y: 85, w: 10, h: 90},
{x: 465, y: 85, w: 225, h: 10},
{x: 685, y: 85, w: 10, h: 25},
{x: 685, y: 150, w: 10, h: 425},
{x: 745, y: 20, w: 10, h: 345},
{x: 745, y: 410, w: 10, h: 120},
{x: 745, y: 525, w: 70, h: 10},
{x: 805, y: 450, w: 10, h: 85},
{x: 865, y: 450, w: 10, h: 85},
{x: 805, y: 445, w: 70, h: 10},
{x: 865, y: 525, w: 140, h: 10},
{x: 745, y: 410, w: 145, h: 10},
{x: 935, y: 410, w: 40, h: 10},
{x: 745, y: 355, w: 170, h: 10},
{x: 905, y: 90, w: 10, h: 275},
{x: 845, y: 90, w: 60, h: 10},
{x: 845, y: 90, w: 10, h: 245},
{x: 785, y: 20, w: 10, h: 315},
{x: 785, y: 325, w: 60, h: 10},
];
}
display() {
for (let i = 0; i < 4; i++) {
let wall = this.walls[i];
image(stoneWall, wall.x, wall.y, wall.w, wall.h);
}
//creating the masking
for (let i = 4; i < this.walls.length; i++) {
let wall = this.walls[i];
// Create a mask for the wall
drawingContext.save();
drawingContext.beginPath();
drawingContext.arc(player.x, player.y, lightRadius, 0, TWO_PI);
drawingContext.clip();
image(stoneWall, wall.x, wall.y, wall.w, wall.h);
drawingContext.restore();
}
}
class Maze { constructor() { this.walls = [ // Outer boundaries (these are always visible) {x: 75, y: 0, w: 1200, h: 30}, // Top {x: 0, y: 570, w: 1200, h: 30}, // Bottom {x: 0, y: 0, w: 30, h: 600}, // Left {x: 970, y: 0, w: 30, h: 535}, // Right // Interior walls (these are only visible within the flashlight radius) {x: 75, y: 25, w: 10, h: 100}, {x: 75, y: 165, w: 10, h: 220}, {x: 75, y: 425, w: 10, h: 90}, {x: 145, y: 25, w: 10, h: 100}, {x: 195, y: 25, w: 10, h: 100}, {x: 195, y: 115, w: 75, h: 10}, {x: 75, y: 115, w: 75, h: 10}, {x: 75, y: 165, w: 200, h: 10}, {x: 265, y:115, w: 10, h: 55}, {x: 75, y: 375, w: 165, h: 10}, {x: 75, y: 425, w: 215, h: 10}, {x: 280, y: 295, w: 10, h: 140}, {x: 235, y: 355, w: 10, h: 30}, {x: 105, y: 345, w: 140, h: 10}, {x: 155, y: 295, w: 135, h: 10}, {x: 155, y: 255, w: 10, h: 50}, {x: 105, y: 205, w: 10, h: 150}, {x: 75, y: 515, w: 210, h: 10}, {x: 280, y: 515, w: 10, h: 60}, {x: 105, y: 490, w: 205, h: 10}, {x: 105, y: 450, w: 205, h: 10}, {x: 105, y: 450, w: 10, h: 50}, {x: 155, y: 255, w: 155, h: 10}, {x: 115, y: 205, w: 195, h: 10}, {x: 300, y:95, w: 10, h: 115}, {x: 235, y: 85, w: 75, h: 10}, {x: 300, y:255, w: 10, h: 205}, {x: 300, y:490, w: 10, h: 85}, {x: 225, y: 25, w: 10, h: 70}, {x: 345, y: 25, w: 10, h: 320}, {x: 345, y: 345, w: 225, h: 10}, {x: 345, y: 395, w: 60, h: 10}, {x: 465, y: 395, w: 60, h: 10}, {x: 345, y: 395, w: 10, h: 180}, {x: 515, y: 395, w: 10, h: 180}, {x: 565, y: 345, w: 10, h: 180}, {x: 565, y: 525, w: 50, h: 10}, {x: 605, y: 300, w: 10, h: 230}, {x: 655, y: 165, w: 10, h: 410}, {x: 405, y: 300, w: 205, h: 10}, {x: 405, y: 255, w: 205, h: 10}, {x: 405, y: 225, w: 205, h: 10}, {x: 605, y: 225, w: 10, h: 40}, {x: 405, y: 255, w: 10, h: 50}, {x: 405, y: 25, w: 10, h: 210}, {x: 465, y: 165, w: 195, h: 10}, {x: 465, y: 85, w: 10, h: 90}, {x: 465, y: 85, w: 225, h: 10}, {x: 685, y: 85, w: 10, h: 25}, {x: 685, y: 150, w: 10, h: 425}, {x: 745, y: 20, w: 10, h: 345}, {x: 745, y: 410, w: 10, h: 120}, {x: 745, y: 525, w: 70, h: 10}, {x: 805, y: 450, w: 10, h: 85}, {x: 865, y: 450, w: 10, h: 85}, {x: 805, y: 445, w: 70, h: 10}, {x: 865, y: 525, w: 140, h: 10}, {x: 745, y: 410, w: 145, h: 10}, {x: 935, y: 410, w: 40, h: 10}, {x: 745, y: 355, w: 170, h: 10}, {x: 905, y: 90, w: 10, h: 275}, {x: 845, y: 90, w: 60, h: 10}, {x: 845, y: 90, w: 10, h: 245}, {x: 785, y: 20, w: 10, h: 315}, {x: 785, y: 325, w: 60, h: 10}, ]; } display() { for (let i = 0; i < 4; i++) { let wall = this.walls[i]; image(stoneWall, wall.x, wall.y, wall.w, wall.h); } //creating the masking for (let i = 4; i < this.walls.length; i++) { let wall = this.walls[i]; // Create a mask for the wall drawingContext.save(); drawingContext.beginPath(); drawingContext.arc(player.x, player.y, lightRadius, 0, TWO_PI); drawingContext.clip(); image(stoneWall, wall.x, wall.y, wall.w, wall.h); drawingContext.restore(); } }
class Maze {
  constructor() {
    this.walls = [
      // Outer boundaries (these are always visible)
      {x: 75,    y: 0,    w: 1200, h: 30},   // Top
      {x: 0,    y: 570,  w: 1200, h: 30},   // Bottom
      {x: 0,    y: 0,    w: 30,    h: 600}, // Left
      {x: 970, y: 0,    w: 30,    h: 535}, // Right

      // Interior walls (these are only visible within the flashlight radius)
      {x: 75,  y: 25,  w: 10,   h: 100},
      {x: 75,  y: 165,  w: 10,   h: 220},
      {x: 75,  y: 425,  w: 10,   h: 90},
      {x: 145,  y: 25,  w: 10,   h: 100},
      {x: 195,  y: 25,  w: 10,   h: 100},
      {x: 195,  y: 115,  w: 75,  h: 10},
      {x: 75,  y: 115,  w: 75,  h: 10},
      {x: 75,  y: 165,  w: 200,  h: 10},
      {x: 265,  y:115,  w: 10,   h: 55},
      {x: 75,  y: 375,  w: 165,  h: 10},
      {x: 75,  y: 425,  w: 215,  h: 10},
      {x: 280,  y: 295,  w: 10,   h: 140},
      {x: 235,  y: 355,  w: 10,   h: 30},
      {x: 105,  y: 345,  w: 140,  h: 10},
      {x: 155,  y: 295,  w: 135,  h: 10},
      {x: 155,  y: 255,  w: 10,   h: 50},
      {x: 105,  y: 205,  w: 10,   h: 150},
      {x: 75,  y: 515,  w: 210,  h: 10},
      {x: 280,  y: 515,  w: 10,  h: 60},
      {x: 105,  y: 490,  w: 205,  h: 10},
      {x: 105,  y: 450,  w: 205,  h: 10},
      {x: 105,  y: 450,  w: 10,  h: 50},
      {x: 155,  y: 255,  w: 155,   h: 10},
      {x: 115,  y: 205,  w: 195,   h: 10},
      {x: 300,  y:95,  w: 10,   h: 115},
      {x: 235,  y: 85,  w: 75,  h: 10},
      {x: 300,  y:255,  w: 10,   h: 205},
      {x: 300,  y:490,  w: 10,   h: 85},
      {x: 225,  y: 25,  w: 10,   h: 70},
      {x: 345,  y: 25,  w: 10,   h: 320},
      {x: 345,  y: 345,  w: 225,   h: 10},
      {x: 345,  y: 395,  w: 60,   h: 10},
      {x: 465,  y: 395,  w: 60,   h: 10},
      {x: 345,  y: 395,  w: 10,   h: 180},
      {x: 515,  y: 395,  w: 10,   h: 180},
      {x: 565,  y: 345,  w: 10,   h: 180},
      {x: 565,  y: 525,  w: 50,   h: 10},
      {x: 605,  y: 300,  w: 10,   h: 230},
      {x: 655,  y: 165,  w: 10,   h: 410},
      {x: 405,  y: 300,  w: 205,   h: 10},
      {x: 405,  y: 255,  w: 205,   h: 10},
      {x: 405,  y: 225,  w: 205,   h: 10},
      {x: 605,  y: 225,  w: 10,   h: 40},
      {x: 405,  y: 255,  w: 10,   h: 50},
      {x: 405,  y: 25,  w: 10,   h: 210},
      {x: 465,  y: 165,  w: 195,   h: 10},
      {x: 465,  y: 85,  w: 10,   h: 90},
      {x: 465,  y: 85,  w: 225,   h: 10},
      {x: 685,  y: 85,  w: 10,   h: 25},
      {x: 685,  y: 150,  w: 10,   h: 425},
      {x: 745,  y: 20,  w: 10,   h: 345},
      {x: 745,  y: 410,  w: 10,   h: 120},
      {x: 745,  y: 525,  w: 70,   h: 10},
      {x: 805,  y: 450,  w: 10,   h: 85},
      {x: 865,  y: 450,  w: 10,   h: 85},
      {x: 805,  y: 445,  w: 70,   h: 10},
      {x: 865,  y: 525,  w: 140,   h: 10},
      {x: 745,  y: 410,  w: 145,   h: 10},
      {x: 935,  y: 410,  w: 40,   h: 10},
      {x: 745,  y: 355,  w: 170,   h: 10},
      {x: 905,  y: 90,  w: 10,   h: 275},
      {x: 845,  y: 90,  w: 60,   h: 10},
      {x: 845,  y: 90,  w: 10,   h: 245},
      {x: 785,  y: 20,  w: 10,   h: 315},
      {x: 785,  y: 325,  w: 60,   h: 10},
    ];
  }

  display() {
    for (let i = 0; i < 4; i++) {
      let wall = this.walls[i];
      image(stoneWall, wall.x, wall.y, wall.w, wall.h);
    }

//creating the masking
    for (let i = 4; i < this.walls.length; i++) {
      let wall = this.walls[i];
      // Create a mask for the wall
      drawingContext.save();
      drawingContext.beginPath();
      drawingContext.arc(player.x, player.y, lightRadius, 0, TWO_PI);
      drawingContext.clip();
      image(stoneWall, wall.x, wall.y, wall.w, wall.h);

      drawingContext.restore();
    }
  }

The outline of the maze itself can be seen in this image below which I took before I made the maze pitch black apart from the flashlight.

The flashlight itself is another thing I am very proud of as I wanted to make it look as natural as the way the light coming from a flashlight looks like. This means it has a bright center and a smooth gradient to transparent. For this effect, I had to learn how to use the blendMode in p5js which allowed me to create it in the easiest way. The code for this can be seen below.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function drawFlashlight() {
fill(0);
noStroke();
rect(0, 0, width, height);
blendMode(SCREEN);
drawingContext.globalCompositeOperation = 'lighter';
// Draw the flashlight gradient
let gradient = drawingContext.createRadialGradient(
player.x, player.y, lightRadius / 4,
player.x, player.y, lightRadius
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
drawingContext.fillStyle = gradient;
drawingContext.beginPath();
drawingContext.arc(player.x, player.y, lightRadius, 0, TWO_PI);
drawingContext.fill();
blendMode(BLEND);
}
function drawFlashlight() { fill(0); noStroke(); rect(0, 0, width, height); blendMode(SCREEN); drawingContext.globalCompositeOperation = 'lighter'; // Draw the flashlight gradient let gradient = drawingContext.createRadialGradient( player.x, player.y, lightRadius / 4, player.x, player.y, lightRadius ); gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); drawingContext.fillStyle = gradient; drawingContext.beginPath(); drawingContext.arc(player.x, player.y, lightRadius, 0, TWO_PI); drawingContext.fill(); blendMode(BLEND); }
function drawFlashlight() {
  fill(0);
  noStroke();
  rect(0, 0, width, height);

  blendMode(SCREEN); 
  drawingContext.globalCompositeOperation = 'lighter'; 

  // Draw the flashlight gradient
  let gradient = drawingContext.createRadialGradient(
    player.x, player.y, lightRadius / 4,
    player.x, player.y, lightRadius 
  );
  gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
  gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); 
  drawingContext.fillStyle = gradient;
  drawingContext.beginPath();
  drawingContext.arc(player.x, player.y, lightRadius, 0, TWO_PI);
  drawingContext.fill();
  blendMode(BLEND);
}

The result of this and the masking can be seen in the image below (with a peep of one the traps – the mummy):

The game proved to be more challenging than I anticipated, and I quickly realized I had set the bar a little to high for myself. Originally, I wanted the player to be a sprite of an archeologist and to have all the booby traps be animated and moving. However, with time constraints and animation struggles, I wasn’t able to do that (but would love to try and incorporate in the future). I also wanted for the cat to somehow appear at the edge of the flashlight’s radius when the ‘C’ key was pressed, the radius of the flashlight expanded, and the meow sound occurred.

Overall, I am still very proud of the game I ended up creating. It is fun, has an overarching Egyptian theme through carefully selected images and even the Papyrus font, and has a niche to it that sets it apart from other games. I learned a lot about p5js and how to code new things while working on this game, and I hope you enjoy playing it!

Midterm Project

Concept

Link to Sketch: https://editor.p5js.org/sa8831/sketches/R-3aQof8X

Inspired by a small app I made during high school (Refer to the blog about Midterm progress),  I decided that for the upcoming midterm project, to create an interactive visual novel game. The game takes place in a school of “The Periodic Table” where chemical elements are personified as classmates and teachers. Throughout the game, the user plays as a personified version of Carbon meets other elements like Hydrogen and oxygen to discuss who they are. However, there are 2 endings to this game, where after interacting with either characters, each gets a different ending and a different activity.

How the game works:

How the Periodic Table Academy Game Works (Updated Version):

  • Starting the Game:
    • When the game starts, a title screen with a Play button appears. Upon clicking the Play button, the game transitions to the instruction screen.
    • Instructions are displayed, welcoming the player to the Periodic Table Academy and explaining that the player can click the Play button again to begin the game. The player is presented with two characters to interact with: Oxygen and Hydrogen. The characters are displayed on the screen, and the player can choose who to talk to by selecting an option like “Talk to Oxygen” or “Talk to Hydrogen”.
    • If the player chooses to talk to Oxygen, they enter the oxygen-talk state. Oxygen responds with different dialogue options like:
      • How are you?
      • Tell me about yourself
      • Bye
    • Based on the selected option, the game displays different responses from Oxygen, with the dialogue box updating each time.
    • If the player selects “Bye”, the game moves into a cutscene where Oxygen says goodbye, and then the game transitions to one of the endings after a short delay (CO2).Hydrogen’s Dialogue:
      • Similarly, if the player chooses to talk to Hydrogen, they enter the hydrogen-talk state. Hydrogen responds with dialogue options such as:
        • How are you?
        • Tell me about yourself
        • Bye
      • The game progresses similarly as with Oxygen, with the player receiving different responses from Hydrogen.
      • Selecting “Bye” leads to a cutscene where Hydrogen says goodbye, followed by a transition to another ending (C-H).

 

Endings:

        • There are two possible endings:
          • CO2 (Carbon Dioxide): If the player interacts with Oxygen, they receive the CO2 ending. The game then displays a message congratulating the player for making CO2.
          • C-H (Hydrocarbon): If the player interacts with Hydrogen, they receive the Hydrocarbon ending. The game congratulates the player for making Hydrocarbons.Interactive Ending:
            • During the ending state, the player can click around the screen to generate particles that represent either CO2 or Hydrocarbons depending on the ending.
            • Particles are displayed on the screen, and each particle shows a label (either “CO2” or “C-H”) as it moves upward. These particles are generated when the player clicks anywhere on the screen.

Game Design

I initially wanted to draw a personified Carbon and allow the user to move using keys A and D to include a walking animation, to go either direction to the characters. However, due to breaking the screen and difficulty to link my spritesheet with the character, I removed that idea but instead made it inspired by the game Undertale:

Undertale Part #12 - Dating Start!

(inspiration for the dialogue)

In addition to this, the endings involves particles that resembles a “bond” between 2 characters. I aim for the particles to represent the compounds for reacting Carbon with either Oxygen or Hydrogen to create molecules (like C-H or Hydrocarbons, and Carbon Dioxide or CO2).

 

Highlights/ Challenges:
The highlights of this would be this snippet:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Particle Class
class Particle {
constructor(x, y, text) {
this.x = x;
this.y = y;
this.size = random(20, 50);
this.text = text;
this.ySpeed = random(-2, -1);
}
move() {
this.y += this.ySpeed; // Move upward
}
display() {
fill('#ADD8E6');
noStroke();
ellipse(this.x, this.y, this.size);
fill(255);
textSize(this.size / 2);
text(this.text, this.x, this.y);
}
}
// Generate particles on click
function mousePressed() {
if (state === 'ending') {
let newParticle = new Particle(mouseX, mouseY, endingType);
particles.push(newParticle);
}
}
// Particle Class class Particle { constructor(x, y, text) { this.x = x; this.y = y; this.size = random(20, 50); this.text = text; this.ySpeed = random(-2, -1); } move() { this.y += this.ySpeed; // Move upward } display() { fill('#ADD8E6'); noStroke(); ellipse(this.x, this.y, this.size); fill(255); textSize(this.size / 2); text(this.text, this.x, this.y); } } // Generate particles on click function mousePressed() { if (state === 'ending') { let newParticle = new Particle(mouseX, mouseY, endingType); particles.push(newParticle); } }
// Particle Class
class Particle {
  constructor(x, y, text) {
    this.x = x;
    this.y = y;
    this.size = random(20, 50);
    this.text = text;
    this.ySpeed = random(-2, -1);
  }

  move() {
    this.y += this.ySpeed; // Move upward
  }

  display() {
    fill('#ADD8E6');
    noStroke();
    ellipse(this.x, this.y, this.size);
    fill(255);
    textSize(this.size / 2);
    text(this.text, this.x, this.y);
  }
}

// Generate particles on click
function mousePressed() {
  if (state === 'ending') {
    let newParticle = new Particle(mouseX, mouseY, endingType);
    particles.push(newParticle);
  }
}

Because of clicking around the ending screen to get the needed “molecule” for each ending.

However, the challenges involves the following:

  1. Ensuring smooth user interaction with the character dialogues was challenging, especially with different choices for interactions such as “How are you?”, “Tell me about yourself?”, and “Bye.”
    • I structured the dialogue system using buttons that allow for straightforward user input. This kept the interactions clean, with each character giving distinct options based on the user’s previous choices. This helped in providing a more guided yet flexible experience.
  2. Since I wanted to integrate Object-Oriented Programming, handling multiple characters (Oxygen and Hydrogen) with unique behaviors, interactions, and background transitions was tricky, especially with the game requiring seamless transitions from one scene to another.
    • I utilized OOP principles to create classes for characters, backgrounds, and game states. This allowed for easy management of multiple elements interacting with each other, while ensuring that transitions and character interactions felt fluid and natural. The background changed dynamically as Carbon moved, which helped convey the progression of the game.
  3. Another challenge was implementing multiple endings depending on which character the player interacts with first and ensuring the game could be easily restarted without refreshing the page.
    • I included an instruction screen and a restart button after each ending. To handle game state management, I kept track of the character interactions and ensured the game’s ending sequences could be triggered based on the order of choices. The restart button was designed to reset the game state while keeping the experience accessible without needing to reload the page.
  4. The game’s concept of displaying chemistry information such as oxygen bonding with carbon, and visual representations of molecules like CO2 and hydrocarbons, needed to be both educational and engaging.
    • I decided to keep the visual representations simple, using floating shapes and rain animations to visually communicate the chemical reactions. This allowed players to understand the results of their choices (CO2 and hydrocarbons) in a non-overwhelming way.

Improvements

  • Enhancing Character Interactions:

    • Add more diverse dialogue options that react to previous choices, making conversations more personalized and dynamic.
  • Expanding the Periodic Table Academy Environment:

    • Introduce additional elements from the periodic table with unique interactions, personalities, and visual effects.
    • Create puzzles or mini-games based on chemical properties to increase educational value.
  • Improved Visual Effects for Chemical Reactions:

    • Implement particle effects, such as glowing or color-changing molecules when bonding occurs, to enhance visual appeal and illustrate chemical reactions more vividly.

Midterm Project

Final Update: Concept

At first, I wanted my project to recreate my old, cluttered workspace in Bangladesh—full of life, creativity, and memories. But after moving into my dorm, I realized my space now is the opposite: empty, open, and full of possibility.

So instead of a busy, object-filled world, my interactive artwork in p5.js reflects this new beginning. The emptiness isn’t just emptiness—it’s potential. It invites exploration, just like my dorm did when I first moved in. This project became more than just a recreation; it’s a reflection of change, growth, and the spaces we make our own.

Midterm Project

For my midterm project, I decided to do a Space Invaders-inspired shooting game with space ships and aliens. The game was partly inspired by a mobile game called: Radiant. The game’s main feature was an array of weapons in which the player could freely switch between. Because each weapon type had its own advantages and disadvantages, you had to switch between all of them to successfully complete the game. I wanted to incorporate this element into my shooter game to make it more unique and be more interactive and replayable.

Game Design

The game begins on a menu screen, where the player sees the game title and has access to all the instructions they need to play the game. I found a really cool arcade-style font that really matched the theme of the game. Because the game instructions were relatively minimal there was no need to have mutliples screens of text.

Then when the game begins the player is spawned and enemies start spawning from the top of the screen at regular intervals, with the interval decreasing over time to increase the difficulty of the game. The enemies path directly towards the player, ending the game if even one of them touches the player. Because of the number of enemies already present, I did not make the enemies fire any shots.

The player had access to 6 different type of weapons, normal shots, machine gun, shot gun, laser and other settings that all had their own characteristic. The player could switch at anytime by pressing the number keys 1-6. Each type of shot deals different amounts of damage to enemies.

When the player is hit by an enemy the game cuts to a GAME OVER screen. The player can then press play again to restart the game.

Challenges

One problem I continuously faced was when the alien sprites because of the way they were coded, were often the wrong size and unable to be resized by the variables. I had to consult  p5 reference for a while to fix this.

Another problem I had faced for a long time was getting the shot type to switch to the proper type of shots. After tutorials and looking at other projects I switching with cases allowed me to control this as well as shot spread with the keyboard numbers.

Lastly, one thing I was surprised was so challenging was assigning a sound to play when the game ended (I ended up forgoing this). There was no way to easily play a sound once without looping when my game state switched.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
damage = 6
break
case 4:
color = [255, 255, 0]
width = 15
height = 10
speed = 12
damage = 1.5
break;
case 5:
color = [255, 50, 255]
width = 8
height = 24
speed = 15
damage = 3
break;
case 6:
color = [170, 50, 255]
width = 5
height = 35
speed = 25
damage = 3
break
}
damage = 6 break case 4: color = [255, 255, 0] width = 15 height = 10 speed = 12 damage = 1.5 break; case 5: color = [255, 50, 255] width = 8 height = 24 speed = 15 damage = 3 break; case 6: color = [170, 50, 255] width = 5 height = 35 speed = 25 damage = 3 break }
  damage = 6
  break
case 4:
  color = [255, 255, 0]
  width = 15
  height = 10
  speed = 12
  damage = 1.5
  break;
case 5:
  color = [255, 50, 255]
  width = 8
  height = 24
  speed = 15
  damage = 3
  break;
case 6: 
  color = [170, 50, 255]
  width = 5
  height = 35
  speed = 25
  damage = 3
  break
}

 

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function keyPressed() {
if (key === '2') {
shotType = 2
cooldown = 150
} else if (key === '1') {
shotType = 1
cooldown = 400
} else if (key === '3') {
shotType = 3
cooldown = 800
} else if (key === '4') {
shotType = 4
cooldown = 600
} else if (key === '5') {
shotType = 5
cooldown = 500
} else if (key === '6') {
shotType = 6
cooldown = 600;
}
}
function keyPressed() { if (key === '2') { shotType = 2 cooldown = 150 } else if (key === '1') { shotType = 1 cooldown = 400 } else if (key === '3') { shotType = 3 cooldown = 800 } else if (key === '4') { shotType = 4 cooldown = 600 } else if (key === '5') { shotType = 5 cooldown = 500 } else if (key === '6') { shotType = 6 cooldown = 600; } }
function keyPressed() {
  if (key === '2') {
    shotType = 2
    cooldown = 150
  } else if (key === '1') {
    shotType = 1
    cooldown = 400
  } else if (key === '3') {
    shotType = 3
    cooldown = 800
  } else if (key === '4') {
    shotType = 4
    cooldown = 600
  } else if (key === '5') {
    shotType = 5
    cooldown = 500
  } else if (key === '6') {
    shotType = 6
    cooldown = 600;
  }
}

 

Future Improvements

There are so many ways to potentially improve this game. One way is to definitely have more enemy types that would resist or be weak towards different types of projectiles. This would further incentivise the player to switch weapons, allowing the game to be more engaging.

Another potential improvement is improving the movement patterns of the aliens to be more challenging. If they moved unpredictably towards the player that allowed them to “dodge” shots, the game would have a whole other level of challenge.

Lastly, A scorekeeping method or score in the top left corner would be very useful and would make the game feel like its progressing- this is probably the first change I would want to implement.

Midterm Project

Concept

I’ve always been fascinated by spooky stories, yet wanted to create something more lighthearted than a typical horror game. That is how I ended up making a short, click-based project in which the player buys a haunted house at a bargain price and must purge it of unwanted guests—literally ghosts—to unlock its true potential. From the outset, I imagined a game with a touch of urgency: each wave of ghosts adds just enough pressure to keep your heart rate up, but not so much as to be punishing or relentlessly scary. The result is a game that blends frantic clicking with a fun, eerie atmosphere and a simple storyline that sets the stakes clearly: fail, and the ghosts overwhelm you; succeed, and the house is yours, free of all things that go bump in the night.

To cement that narrative, I designed three distinct levels, each with its own flavor of tension. Level 1’s stationary ghosts ease the player in, Level 2’s moving ghosts ramp up speed and require more hits, and Level 3 features a boss ghost who teleports unpredictably to keep the player on their toes. Across these levels, the fundamental goal never changes—click fast, or you risk running out of time—but the variety of ghost behavior and the gradually intensifying difficulty create a neat progression. The final reward is a bright, newly revealed house, signifying the end of the haunting and the triumph of actually getting to live in your newly bought property.

How the Project Works

The code behind this game employs a simple state machine to orchestrate its various parts. I found this approach more intuitive than cramming all logic into one giant loop. Whenever the player transitions from, say, the INTRO state to LEVEL1, the script calls startLevel(level), which resets the timer, spawns the appropriate ghosts, and ensures there’s no carry-over from earlier states. Here is a small excerpt that illustrates how the game transitions from one level to the next or ends if all ghosts are defeated:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
if (ghosts.length === 0) {
if (level === 1) {
state = "LEVEL2";
startLevel(2);
} else if (level === 2) {
state = "LEVEL3";
startLevel(3);
} else if (level === 3) {
state = "WIN";
ghostSound.stop();
victorySound.play();
}
}
if (ghosts.length === 0) { if (level === 1) { state = "LEVEL2"; startLevel(2); } else if (level === 2) { state = "LEVEL3"; startLevel(3); } else if (level === 3) { state = "WIN"; ghostSound.stop(); victorySound.play(); } }
if (ghosts.length === 0) {
  if (level === 1) {
    state = "LEVEL2";
    startLevel(2);
  } else if (level === 2) {
    state = "LEVEL3";
    startLevel(3);
  } else if (level === 3) {
    state = "WIN";
    ghostSound.stop();
    victorySound.play();
  }
}

I’m particularly proud of how coherent each ghost type’s logic is, thanks to object-oriented design. I broke down the ghosts into three classes—StaticGhost, MovingGhost, and BossGhost—so each can handle its own movement and hit requirements. For instance, the MovingGhost class includes velocities in both x and y directions. It updates its position each frame and bounces off the canvas edges, which felt more elegant than scattering if-conditions all over the main draw() loop. This design choice also eases extension: if I someday add a new ghost that splits into two smaller ghosts on hit, I can isolate that behavior in yet another class.

Moreover, I love the inclusion of sound elements, as it heightens the atmosphere. The ghostly background audio starts the moment you tackle Level 1, giving a sense of stepping into a haunted property. On every successful hit, a short “blast” sound triggers, reinforcing the feedback loop for players clicking in a hurry. When the boss ghost is defeated, the game transitions to a “WIN” screen with a triumphant sound, showing off the newly purified house. These little touches bring a pleasant cohesiveness to the otherwise basic act of clicking on sprites and add a layer of fun that purely visual feedback might not achieve.

Areas for Improvement & Challenges Faced

While I’m delighted with the final result, I definitely noticed some spots that could be enhanced through further iteration. First, game balancing was tricky. If I set the time limits too high, the game lost its tension because players could leisurely pick off ghosts. If I made them too strict, new players often found themselves losing before they fully grasped the ghost patterns. I settled on modest values for each level, but it still demands some quick reflexes—if someone isn’t used to rapid clicking, they might find the default times a bit harsh. Introducing difficulty modes, or maybe timed boosts that freeze the ghosts for a short period, would give me more fine control over that difficulty curve and cater to different skill levels.

Another challenge was ensuring that state transitions stayed seamless. At one point, I had ghosts from Level 1 lingering into Level 2, causing the dreaded “two waves at once” bug. The fix involved carefully resetting arrays and timers inside startLevel(level) and confirming no leftover references were carried over. Though it took some debugging, it taught me the importance of thorough housekeeping when cycling through game phases—particularly in an experience that uses back-to-back waves, each with its own unique ghost behaviors. If I expand the project in the future, perhaps adding more levels or extra ghost abilities, I’ll rely heavily on these same organizational principles to keep the code from becoming unwieldy and prone to surprises. I feel the game meets my initial ambition: to craft a short, fun, and spooky challenge that doesn’t overstay its welcome. It balances straightforward gameplay—just point-and-click to vanquish ghosts—with enough variety to keep the three levels distinct and interesting. As I continue refining or building upon the concept, I plan to experiment with new ghost types or reward systems that push the idea even further, but I’m proud of how the current version stands on its own as a playful haunted-house journey.

Midterm Project – Space Shooter

Concept:
My midterm project was inspired by classic arcade games like Space Invaders and Galaga. I wanted to create my own shoot ‘em up game set in space, and try to capture that retro feel while throwing some new elements into the mix.
I made the choice to have the player character and their bullets be pixelated, while the enemies have a more modern look and shoot laser beams in order to create some distinct contrast. I also introduced additional obstacles in the form of slow-moving asteroids and diagonally-moving comets, both of which share in the more modern appearance. I only included sound effects relevant to the player character, so they lean towards the older side.

How it Works:
As for the actual gameplay, I decided to confine the player to a single static screen, where obstacles (including enemies) will continually descend from the top. Since I didn’t design multiple levels or an upward scrolling mechanic, I chose to increase the spawn rate of obstacles as the round goes on to increase the difficulty. The player will continue until their health runs out, at which point they are prompted to save their score.
The player earns points by shooting and destroying an obstacle, but they will also earn a smaller amount for simply allowing the obstacle to pass them and be cleared off-screen. If the player collides with the obstacle, they will instead lose points and destroy the obstacle.

Highlights:
Using OOP to manage different obstacle sub-types + Detecting and handling collisions during gameplay:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Obstacle {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 32;
this.health = 3;
}
display() {
this.update();
circle(this.x,this.y,this.radius*2);
}
update() {
if (gameRunning()) {
this.y += 0.5;
}
}
check() {
if (this.health <= 0) {return}
// // Flag obstacles below screen to be cleared
if (!onScreen(this)) {
// console.log("Destroy offscreen");
this.health = 0;
game.score += 5;
}
// // Check collision w/ player
if (checkCollision(this, game.player)) {
this.health = 0;
if (!game.player.shielded) {
game.score -= 10;
game.player.health -= 1;
playHurt();
if (game.player.health <= 0) {return}
game.player.iframes = 60;
game.player.shielded = true;
}
}
// // Check collision w/ player bullets
for (let i = 0; i < game.player.bullets.length; i++) {
if (checkCollision(this, game.player.bullets[i])) {
if (!game.player.bullets[i].spent) {
game.player.bullets[i].spent = true;
this.health -= 1;
if (this.health <= 0) {
game.score += 20;
}
}
}
}
}
}
class Enemy extends Obstacle{
constructor(x, y) {
// // Gameplay attributes
super(x, y);
this.radius = 32;
this.sprite = enemySprite;
this.firerate = 0.3;
this.offset = random(1000);
}
display() {
this.update();
image(this.sprite, this.x, this.y, 64, 60);
if (debug) {circle(this.x, this.y, this.radius*2)}
}
update() {
if (gameRunning() && onScreen(this)) {
this.x += sin((frameCount + this.offset) * 0.05) * 2;
this.y += 0.5;
if (triggerFire(this.firerate)) {
append(game.enemyBullets, new Bullet(
this.x, this.y+this.radius));
}
}
}
}
class Obstacle { constructor(x, y) { this.x = x; this.y = y; this.radius = 32; this.health = 3; } display() { this.update(); circle(this.x,this.y,this.radius*2); } update() { if (gameRunning()) { this.y += 0.5; } } check() { if (this.health <= 0) {return} // // Flag obstacles below screen to be cleared if (!onScreen(this)) { // console.log("Destroy offscreen"); this.health = 0; game.score += 5; } // // Check collision w/ player if (checkCollision(this, game.player)) { this.health = 0; if (!game.player.shielded) { game.score -= 10; game.player.health -= 1; playHurt(); if (game.player.health <= 0) {return} game.player.iframes = 60; game.player.shielded = true; } } // // Check collision w/ player bullets for (let i = 0; i < game.player.bullets.length; i++) { if (checkCollision(this, game.player.bullets[i])) { if (!game.player.bullets[i].spent) { game.player.bullets[i].spent = true; this.health -= 1; if (this.health <= 0) { game.score += 20; } } } } } } class Enemy extends Obstacle{ constructor(x, y) { // // Gameplay attributes super(x, y); this.radius = 32; this.sprite = enemySprite; this.firerate = 0.3; this.offset = random(1000); } display() { this.update(); image(this.sprite, this.x, this.y, 64, 60); if (debug) {circle(this.x, this.y, this.radius*2)} } update() { if (gameRunning() && onScreen(this)) { this.x += sin((frameCount + this.offset) * 0.05) * 2; this.y += 0.5; if (triggerFire(this.firerate)) { append(game.enemyBullets, new Bullet( this.x, this.y+this.radius)); } } } }
class Obstacle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.radius = 32;
    this.health = 3;
  }
  display() {
    this.update();
    circle(this.x,this.y,this.radius*2);
  }
  update() {
    if (gameRunning()) {
      this.y += 0.5;
    }
  }
  check() {
    if (this.health <= 0) {return}
    // // Flag obstacles below screen to be cleared
    if (!onScreen(this)) {
      // console.log("Destroy offscreen");
      this.health = 0;
      game.score += 5;
    }
    // // Check collision w/ player
    if (checkCollision(this, game.player)) {
      this.health = 0;
      if (!game.player.shielded) {
        game.score -= 10;
        game.player.health -= 1;
        playHurt();
        if (game.player.health <= 0) {return}
        game.player.iframes = 60;
        game.player.shielded = true;
      }
    }
    // // Check collision w/ player bullets
    for (let i = 0; i < game.player.bullets.length; i++) {
      if (checkCollision(this, game.player.bullets[i])) {
        if (!game.player.bullets[i].spent) {
          game.player.bullets[i].spent = true;
          this.health -= 1;
          if (this.health <= 0) {
            game.score += 20;
          }
        }
      }
    }
  }
}
class Enemy extends Obstacle{
  constructor(x, y) {
    // // Gameplay attributes
    super(x, y);
    this.radius = 32;
    this.sprite = enemySprite;
    this.firerate = 0.3;
    this.offset = random(1000);
  }
  display() {
    this.update();
    image(this.sprite, this.x, this.y, 64, 60);
    if (debug) {circle(this.x, this.y, this.radius*2)}
  }
  update() {
    if (gameRunning() && onScreen(this)) {
      this.x += sin((frameCount + this.offset) * 0.05) * 2;
      this.y += 0.5;
      if (triggerFire(this.firerate)) {
        append(game.enemyBullets, new Bullet(
          this.x, this.y+this.radius));
      }
    }
  }
}

Using helper functions instead of cramming everything in-line:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// // Determine whether to shoot on current frame
function triggerFire(rate) {
let trigger = Math.floor(60 / rate);
return frameCount % trigger === 0;
}
// // Check collision of two objects (circle bounding)
function checkCollision(one, two) {
if (dist(one.x, one.y, two.x, two.y) <=
(one.radius + two.radius)) {return true}
return false;
}
// // Spawn a new enemy/asteroid/comet
function spawnObstacle(type) {
let newX = Math.floor(random(30, width - 30));
let newY = Math.floor(random(-50, -200));
let obs;
switch(type) {
case 0:
obs = new Enemy(newX, newY);
append(game.enemies, obs);
break;
case 1:
obs = new Asteroid(newX, newY);
append(game.asteroids, obs);
break;
case 2:
newX = newX % 30;
obs = new Comet(newX, newY);
append(game.comets, obs);
break;
default:
console.log("Spawn " + type);
}
append(game.obstacles[type], obs);
if (debug) {console.log("Spawn new " + type)}
}
// // Standardize button styling for menu
function doStyle(elem) {
// // Styling
elem.style("font-size", "16px");
elem.style("font-family", "sans-serif");
elem.style("background-color", "rgb(20,20,20)");
elem.style("color", "whitesmoke");
elem.style("cursor", "pointer");
// // Hover behaviour
elem.mouseOver(() => {
elem.style("background-color", "firebrick");
});
elem.mouseOut(() => {
elem.style("background-color", "rgb(20, 20, 20)");
});
}
// // Handle gameover
function triggerGameover() {
gameState = 'gameover';
game_bgm.stop();
// player_shoot.stop();
// player_hurt.stop();
if (game) {
game.gameover = true;
// // Play win sound for score (temp)
if (game.score > 500) {
win_sound.play();
return;
}
}
lose_sound.play();
}
// // Determine whether to shoot on current frame function triggerFire(rate) { let trigger = Math.floor(60 / rate); return frameCount % trigger === 0; } // // Check collision of two objects (circle bounding) function checkCollision(one, two) { if (dist(one.x, one.y, two.x, two.y) <= (one.radius + two.radius)) {return true} return false; } // // Spawn a new enemy/asteroid/comet function spawnObstacle(type) { let newX = Math.floor(random(30, width - 30)); let newY = Math.floor(random(-50, -200)); let obs; switch(type) { case 0: obs = new Enemy(newX, newY); append(game.enemies, obs); break; case 1: obs = new Asteroid(newX, newY); append(game.asteroids, obs); break; case 2: newX = newX % 30; obs = new Comet(newX, newY); append(game.comets, obs); break; default: console.log("Spawn " + type); } append(game.obstacles[type], obs); if (debug) {console.log("Spawn new " + type)} } // // Standardize button styling for menu function doStyle(elem) { // // Styling elem.style("font-size", "16px"); elem.style("font-family", "sans-serif"); elem.style("background-color", "rgb(20,20,20)"); elem.style("color", "whitesmoke"); elem.style("cursor", "pointer"); // // Hover behaviour elem.mouseOver(() => { elem.style("background-color", "firebrick"); }); elem.mouseOut(() => { elem.style("background-color", "rgb(20, 20, 20)"); }); } // // Handle gameover function triggerGameover() { gameState = 'gameover'; game_bgm.stop(); // player_shoot.stop(); // player_hurt.stop(); if (game) { game.gameover = true; // // Play win sound for score (temp) if (game.score > 500) { win_sound.play(); return; } } lose_sound.play(); }
// // Determine whether to shoot on current frame
function triggerFire(rate) {
  let trigger = Math.floor(60 / rate);
  return frameCount %  trigger === 0;
}
// // Check collision of two objects (circle bounding)
function checkCollision(one, two) {
  if (dist(one.x, one.y, two.x, two.y) <= 
      (one.radius + two.radius)) {return true}
  return false;
}
// // Spawn a new enemy/asteroid/comet
function spawnObstacle(type) {
  let newX = Math.floor(random(30, width - 30));
  let newY = Math.floor(random(-50, -200));
  let obs;
  switch(type) {
    case 0:
      obs = new Enemy(newX, newY);
      append(game.enemies, obs);
      break;
    case 1:
      obs = new Asteroid(newX, newY);
      append(game.asteroids, obs);
      break;
    case 2:
      newX = newX % 30;
      obs = new Comet(newX, newY);
      append(game.comets, obs);
      break;
    default:
      console.log("Spawn " + type);
  }
  append(game.obstacles[type], obs);
  if (debug) {console.log("Spawn new " + type)}
}
// // Standardize button styling for menu
function doStyle(elem) {
  // // Styling
  elem.style("font-size", "16px");
  elem.style("font-family", "sans-serif");
  elem.style("background-color", "rgb(20,20,20)");
  elem.style("color", "whitesmoke");
  elem.style("cursor", "pointer");
  // // Hover behaviour
  elem.mouseOver(() => {
    elem.style("background-color", "firebrick");
  });
  elem.mouseOut(() => {
    elem.style("background-color", "rgb(20, 20, 20)");
  });
}
// // Handle gameover
function triggerGameover() {
  gameState = 'gameover';
  game_bgm.stop();
  // player_shoot.stop();
  // player_hurt.stop();
  if (game) {
    game.gameover = true;
    // // Play win sound for score (temp)
    if (game.score > 500) {
      win_sound.play();
      return;
    }
  }
  lose_sound.play();
}

Areas for Improvement:
Since I spent most of my time on the gameplay itself, I feel like I lost out on the visual appeal in several places. For example, my menu screen has some styling on the buttons, but the font is fairly standard instead of using a pixelated font to add to the retro aesthetic. I also had started to work on a leaderboard system, but I belatedly realized that it would download a CSV file to the computer instead of saving the results to the relevant asset file.
Another area for improvement is the gameplay itself. I began to add power ups that would spawn in the playable area, but didn’t have time to fully implement them. The sound design was also a last minute addition that I completely forgot about until I started writing the blog post. Finally, the difficulty ramp-up could use some adjustment. Each obstacle type has semi-randomized parameters within differing bounds, but it still feels like something is a bit off about it.


(Fullscreen)

Midterm Project : Pixel Art

For my midterm project, I drew inspiration from the color-by-number games that are so popular on the internet. I used to be obsessed with them, so I decided to create my own pixelated version. In my project, users color cell by cell in a grid, where each cell corresponds to a letter, and each color on the palette is assigned to a letter as well. By following the key and filling in the grid, the design gradually comes to life, revealing the full picture bit by bit. There’s something incredibly relaxing about mindlessly clicking, watching the colors take shape, and seeing the image emerge one step at a time. It’s a simple yet satisfying process, where every small action contributes to a larger, beautiful result.

The game starts with a welcome screen where the user has two options: Start, to begin coloring immediately, or Help, which provides instructions on the various features the game offers. Once they click Start, they are taken to the selection screen, where they can choose a coloring page from the available options. After selecting a page, they arrive at the actual coloring screen, where the process is simple—choose a color from the palette above and start coloring. However, if the user accidentally fills in the wrong square, they can double-tap the cell to remove the color. If they wish to start over or choose another page, a Reset button clears the canvas. Once the user finishes coloring their chosen page, the game is considered complete, and they are prompted to restart if they wish to play/color again.


One aspect of my code that I’m particularly proud of is the implementation of the actual coloring page. The way the cells fill in over the image has a seamless and polished look. I also like how intuitive the overall user interface is—even first-time users can start playing without needing to read the instructions. I chose soothing shades of brown for the interface, paired with lofi background music, to enhance the game’s relaxing atmosphere.

That said, I encountered a few challenges along the way, particularly with the coloring process itself. One major issue was ensuring that the grid, which tracks cell positions, perfectly aligned with the image. Initially, a double grid effect was visible on top of the image, which was distracting. To fix this, I had to experiment with different parameters before finally finding a formula that worked—provided the image was a perfect square with no empty or extra areas. This was implemented in the ColoringPage class. Once the grid was properly aligned, tracking mouse clicks and filling in the correct areas became much smoother.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class ColoringPage {
constructor(name, imagePath,thumbPath, rows, cols, palette,targetCells) {
this.name = name;
this.img = pageImages[name.toLowerCase()];
this.thumb = pageThumbnails[name.toLowerCase()];
this.rows = rows;
this.cols = cols;
this.cellSize = 600 / this.cols;
this.grid = Array.from({ length: this.rows }, () => Array(this.cols).fill(null));
this.palette = palette;
this.selectedColor = Object.values(palette)[0].color;
this.targetCells = targetCells;
this.filledCells = 0;
}
display() {
this.drawPalette();
image(this.img, 100, 90, 600, 600);
this.drawGrid();
this.drawColoredGrid();
}
drawGrid() {
stroke(0, 50);
noFill();
for (let row = 0; row < this.rows; row++) {
for (let col = 0; col < this.cols; col++) {
rect(100 + col * this.cellSize, 90 + row * this.cellSize, this.cellSize, this.cellSize);
}
}
}
drawColoredGrid() {
for (let row = 0; row < this.rows; row++) {
for (let col = 0; col < this.cols; col++) {
if (this.grid[row][col]) {
fill(this.grid[row][col]);
rect(100 + col * this.cellSize, 90 + row * this.cellSize, this.cellSize, this.cellSize);
}
}
}
}
drawPalette() {
let keys = Object.keys(this.palette);
let x = (width - keys.length * 60) / 2;
let y = 20;
noStroke();
for (let i = 0; i < keys.length; i++) {
let colorValue = this.palette[keys[i]].color;
let isSelected = this.selectedColor === colorValue;
let isHovered = mouseX > x + i * 60 && mouseX < x + i * 60 + 50 &&
mouseY > y && mouseY < y + 50;
let circleSize = 50;
if (isHovered) circleSize = 55;
let centerX = x + i * 60 + 30;
let centerY = y + 25;
if (isSelected) {
fill(255);
ellipse(centerX, centerY, circleSize + 8, circleSize + 8);
}
fill(colorValue);
ellipse(centerX, centerY, circleSize, circleSize);
let c = color(colorValue);
let brightnessValue = (red(c) * 0.299 + green(c) * 0.587 + blue(c) * 0.114);
fill(brightnessValue < 128 ? 255 : 0);
textSize(14);
textAlign(CENTER, CENTER);
let labelChar = this.palette[keys[i]].label;
text(labelChar, centerX, centerY);
}
}
selectColor() {
let keys = Object.keys(this.palette);
let x = (width - keys.length * 60) / 2;
for (let i = 0; i < keys.length; i++) {
if (mouseX > x + i * 60 && mouseX < x + i * 60 + 50 && mouseY > 20 && mouseY < 70) {
this.selectedColor = this.palette[keys[i]].color;
break;
}
}
}
fillCell() {
let col = floor((mouseX - 100) / this.cellSize);
let row = floor((mouseY - 90) / this.cellSize);
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
if (!this.grid[row][col]) {
this.grid[row][col] = this.selectedColor;
this.filledCells++;
console.log(this.filledCells)
if (this.isCompleted()) {
game.state = "final";
}
}
}
}
class ColoringPage { constructor(name, imagePath,thumbPath, rows, cols, palette,targetCells) { this.name = name; this.img = pageImages[name.toLowerCase()]; this.thumb = pageThumbnails[name.toLowerCase()]; this.rows = rows; this.cols = cols; this.cellSize = 600 / this.cols; this.grid = Array.from({ length: this.rows }, () => Array(this.cols).fill(null)); this.palette = palette; this.selectedColor = Object.values(palette)[0].color; this.targetCells = targetCells; this.filledCells = 0; } display() { this.drawPalette(); image(this.img, 100, 90, 600, 600); this.drawGrid(); this.drawColoredGrid(); } drawGrid() { stroke(0, 50); noFill(); for (let row = 0; row < this.rows; row++) { for (let col = 0; col < this.cols; col++) { rect(100 + col * this.cellSize, 90 + row * this.cellSize, this.cellSize, this.cellSize); } } } drawColoredGrid() { for (let row = 0; row < this.rows; row++) { for (let col = 0; col < this.cols; col++) { if (this.grid[row][col]) { fill(this.grid[row][col]); rect(100 + col * this.cellSize, 90 + row * this.cellSize, this.cellSize, this.cellSize); } } } } drawPalette() { let keys = Object.keys(this.palette); let x = (width - keys.length * 60) / 2; let y = 20; noStroke(); for (let i = 0; i < keys.length; i++) { let colorValue = this.palette[keys[i]].color; let isSelected = this.selectedColor === colorValue; let isHovered = mouseX > x + i * 60 && mouseX < x + i * 60 + 50 && mouseY > y && mouseY < y + 50; let circleSize = 50; if (isHovered) circleSize = 55; let centerX = x + i * 60 + 30; let centerY = y + 25; if (isSelected) { fill(255); ellipse(centerX, centerY, circleSize + 8, circleSize + 8); } fill(colorValue); ellipse(centerX, centerY, circleSize, circleSize); let c = color(colorValue); let brightnessValue = (red(c) * 0.299 + green(c) * 0.587 + blue(c) * 0.114); fill(brightnessValue < 128 ? 255 : 0); textSize(14); textAlign(CENTER, CENTER); let labelChar = this.palette[keys[i]].label; text(labelChar, centerX, centerY); } } selectColor() { let keys = Object.keys(this.palette); let x = (width - keys.length * 60) / 2; for (let i = 0; i < keys.length; i++) { if (mouseX > x + i * 60 && mouseX < x + i * 60 + 50 && mouseY > 20 && mouseY < 70) { this.selectedColor = this.palette[keys[i]].color; break; } } } fillCell() { let col = floor((mouseX - 100) / this.cellSize); let row = floor((mouseY - 90) / this.cellSize); if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) { if (!this.grid[row][col]) { this.grid[row][col] = this.selectedColor; this.filledCells++; console.log(this.filledCells) if (this.isCompleted()) { game.state = "final"; } } } }
class ColoringPage {
  constructor(name, imagePath,thumbPath, rows, cols, palette,targetCells) {
    this.name = name;
    this.img = pageImages[name.toLowerCase()];  
    this.thumb = pageThumbnails[name.toLowerCase()];
    this.rows = rows;
    this.cols = cols;
    this.cellSize = 600 / this.cols;
    this.grid = Array.from({ length: this.rows }, () => Array(this.cols).fill(null));
    this.palette = palette;
    this.selectedColor = Object.values(palette)[0].color;
    this.targetCells = targetCells; 
    this.filledCells = 0;
  }

  display() {
    this.drawPalette();
    image(this.img, 100, 90, 600, 600);
    this.drawGrid();
    this.drawColoredGrid();
  }

  drawGrid() {
    stroke(0, 50);
    noFill();
    for (let row = 0; row < this.rows; row++) {
      for (let col = 0; col < this.cols; col++) {
        rect(100 + col * this.cellSize, 90 + row * this.cellSize, this.cellSize, this.cellSize);
      }
    }
  }

  drawColoredGrid() {
    for (let row = 0; row < this.rows; row++) {
      for (let col = 0; col < this.cols; col++) {
        if (this.grid[row][col]) {
          fill(this.grid[row][col]);
          rect(100 + col * this.cellSize, 90 + row * this.cellSize, this.cellSize, this.cellSize);
        }
      }
    }
  }

  drawPalette() {
    let keys = Object.keys(this.palette);
    let x = (width - keys.length * 60) / 2;
    let y = 20;

    noStroke();

    for (let i = 0; i < keys.length; i++) {
        let colorValue = this.palette[keys[i]].color;
        let isSelected = this.selectedColor === colorValue;
        let isHovered = mouseX > x + i * 60 && mouseX < x + i * 60 + 50 &&
                        mouseY > y && mouseY < y + 50;

        let circleSize = 50; 
        if (isHovered) circleSize = 55; 

        let centerX = x + i * 60 + 30;
        let centerY = y + 25;

        if (isSelected) {
            fill(255); 
            ellipse(centerX, centerY, circleSize + 8, circleSize + 8);
        }

        fill(colorValue);
        ellipse(centerX, centerY, circleSize, circleSize); 
      
      let c = color(colorValue);
      let brightnessValue = (red(c) * 0.299 + green(c) * 0.587 + blue(c) * 0.114); 

        fill(brightnessValue < 128 ? 255 : 0);
        textSize(14);
        textAlign(CENTER, CENTER);
        
        let labelChar = this.palette[keys[i]].label;  
        text(labelChar, centerX, centerY);
    }
}


  selectColor() {
    let keys = Object.keys(this.palette);
    let x = (width - keys.length * 60) / 2;
    for (let i = 0; i < keys.length; i++) {
      if (mouseX > x + i * 60 && mouseX < x + i * 60 + 50 && mouseY > 20 && mouseY < 70) {
        this.selectedColor = this.palette[keys[i]].color;
        break;
      }
    }
  }

  fillCell() {
    let col = floor((mouseX - 100) / this.cellSize);
    let row = floor((mouseY - 90) / this.cellSize);

    if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
      if (!this.grid[row][col]) {
        this.grid[row][col] = this.selectedColor;
        this.filledCells++;
        console.log(this.filledCells)
        if (this.isCompleted()) {
          game.state = "final";
        }
      }
    }
  }

Another challenge I faced was determining the end condition for the game. Currently, the game ends when the total number of filled cells matches the number of cells corresponding to a color on the grid. However, this assumes that users only color the cells with assigned letters and don’t fill in any blank spaces. Additionally, since the game allows users to fill any cell with any color, the code does not track whether a specific cell is filled with the correct color. A possible solution would be to store a reference for each cell’s correct color in every coloring page, but I haven’t yet found a way to do this dynamically without hardcoding hundreds of cells, which would be inefficient and take up unnecessary space in the code. This is an area I would like to improve in the future.

Midterm Project – The Magic Studio

Concept

The game is called The Magic Studio. The original idea was to create a highly interactive room where users could generate anything they wanted—sounds, objects, and more—giving them full creative freedom. However, due to time constraints, I scaled the project down to a painting game. In the current version, users are given a reference painting, and they must recreate it by generating objects that match the original as closely as possible. The goal is to maintain an element of creativity while also challenging players to be precise in their recreations.

How it works

The game starts with an instructions screen. It then moves the scene where the user can generate objects to match the desired painting. New objects can be generated by prompting again and the previous one gets removed automatically. Once the user is satisfied with their design, they can click on the next button to move to the next painting. Once all the painting tasks are completed, the user can restart the game.

Challenges

One of the biggest challenges and something that I am the most proud of was making the language model (LLM) generate objects as accurately as possible. Initially, I used a model with limited capabilities, which struggled to create detailed objects correctly. This led to some frustrating results, as the generated objects often didn’t match the intended design. Eventually, I switched to the Gemini model, which significantly improved performance. The new model generated objects more accurately, making the gameplay experience much smoother and more enjoyable.

Another challenge was ensuring that the interaction between the user and the game felt intuitive. Since p5.js is primarily a visual tool, integrating AI-based object generation in a way that seamlessly fit into the game mechanics took a lot of trial and error.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Create input field and Gemini button, but hide them until the game starts
promptInput = createInput("Provide Object Description");
promptInput.position(350, 100);
promptInput.hide();
button = createButton("Create your painting!");
button.position(promptInput.x+20,promptInput.y + promptInput.height + 10);
button.mousePressed(() => {
let userText = promptInput.value();
let geminiPrompt = `
You are an AI code generator working within an online p5.js editor environment. In this environment, the following conditions apply:
- The p5.js library is loaded along with its DOM addon (p5.dom), so functions like createCanvas, createButton, and createInput are available.
- A canvas of 800x600 pixels is created as part of a "dream room" simulation.
- The dream room maintains an array of dream objects. Each dream object must be defined as a JavaScript object with the following properties:
- x: a numeric value representing the horizontal coordinate (default value: 100)
- y: a numeric value representing the vertical coordinate (default value: 100)
- size: a numeric value representing the object’s size (default value: 50)
- draw: a function that uses p5.js drawing commands (for example, ellipse) to render the object at (x, y) using its size. Ensure you combine multiple shapes for a rich rendering.
- move: a function that accepts two parameters, dx and dy, and updates the x and y coordinates respectively
Your task is to generate a valid p5.js code snippet that creates a new dream object (named \`dreamObj\`) with these properties. The object must be defined using "let".
Output Requirements:
- Your output must be a valid JSON object with exactly two keys: "code" and "description".
- The "code" key’s value must be a string containing the p5.js code that defines the dream object as described.
- The "description" key’s value must be a concise explanation of what the code does.
- Do not include any additional keys or text; output only the JSON.
Now, generate the object:
`;
let prompt = geminiPrompt + userText;
generateDreamObject(prompt);
});
button.hide();
// Create input field and Gemini button, but hide them until the game starts promptInput = createInput("Provide Object Description"); promptInput.position(350, 100); promptInput.hide(); button = createButton("Create your painting!"); button.position(promptInput.x+20,promptInput.y + promptInput.height + 10); button.mousePressed(() => { let userText = promptInput.value(); let geminiPrompt = ` You are an AI code generator working within an online p5.js editor environment. In this environment, the following conditions apply: - The p5.js library is loaded along with its DOM addon (p5.dom), so functions like createCanvas, createButton, and createInput are available. - A canvas of 800x600 pixels is created as part of a "dream room" simulation. - The dream room maintains an array of dream objects. Each dream object must be defined as a JavaScript object with the following properties: - x: a numeric value representing the horizontal coordinate (default value: 100) - y: a numeric value representing the vertical coordinate (default value: 100) - size: a numeric value representing the object’s size (default value: 50) - draw: a function that uses p5.js drawing commands (for example, ellipse) to render the object at (x, y) using its size. Ensure you combine multiple shapes for a rich rendering. - move: a function that accepts two parameters, dx and dy, and updates the x and y coordinates respectively Your task is to generate a valid p5.js code snippet that creates a new dream object (named \`dreamObj\`) with these properties. The object must be defined using "let". Output Requirements: - Your output must be a valid JSON object with exactly two keys: "code" and "description". - The "code" key’s value must be a string containing the p5.js code that defines the dream object as described. - The "description" key’s value must be a concise explanation of what the code does. - Do not include any additional keys or text; output only the JSON. Now, generate the object: `; let prompt = geminiPrompt + userText; generateDreamObject(prompt); }); button.hide();
  // Create input field and Gemini button, but hide them until the game starts
  promptInput = createInput("Provide Object Description");
  promptInput.position(350, 100);
  promptInput.hide();
  
  button = createButton("Create your painting!");
  button.position(promptInput.x+20,promptInput.y + promptInput.height + 10);
  button.mousePressed(() => {
    let userText = promptInput.value();
    let geminiPrompt = `
You are an AI code generator working within an online p5.js editor environment. In this environment, the following conditions apply:
- The p5.js library is loaded along with its DOM addon (p5.dom), so functions like createCanvas, createButton, and createInput are available.
- A canvas of 800x600 pixels is created as part of a "dream room" simulation.
- The dream room maintains an array of dream objects. Each dream object must be defined as a JavaScript object with the following properties:
    - x: a numeric value representing the horizontal coordinate (default value: 100)
    - y: a numeric value representing the vertical coordinate (default value: 100)
    - size: a numeric value representing the object’s size (default value: 50)
    - draw: a function that uses p5.js drawing commands (for example, ellipse) to render the object at (x, y) using its size. Ensure you combine multiple shapes for a rich rendering.
    - move: a function that accepts two parameters, dx and dy, and updates the x and y coordinates respectively

Your task is to generate a valid p5.js code snippet that creates a new dream object (named \`dreamObj\`) with these properties. The object must be defined using "let".

Output Requirements:
- Your output must be a valid JSON object with exactly two keys: "code" and "description".
- The "code" key’s value must be a string containing the p5.js code that defines the dream object as described.
- The "description" key’s value must be a concise explanation of what the code does.
- Do not include any additional keys or text; output only the JSON.

Now, generate the object: 
`;
    let prompt = geminiPrompt + userText;
    generateDreamObject(prompt);
  });
  button.hide();

 

Future Improvements

There are the ways I can enhance the project in the future:

  1. AI-Based Scoring System – One idea is to allow players to take a screenshot of their generated painting, and then use AI to analyze it and give a score based on accuracy and similarity to the reference image.
  2. AI-Generated Reference Objects – Instead of only providing a static reference painting, we could allow AI to generate a new image based on the original reference. The AI could create a new rendition of the image in a slightly altered style, and players could then attempt to recreate that version using p5.js.
  3. Comparing AI vs. Player Renderings – We could take screenshots of both the AI-generated image and the player-generated image, then compare them using an AI model to determine which one is a better match to the original reference. This would add another layer of challenge and gamification to the experience.
  4. More Creative Freedom – To bring the project closer to the original concept, I could add more interactive elements, such as sound generation or more diverse object creation tools, allowing users to express their creativity beyond just painting.

Midterm Project –

Concept

Retro-style 2D shooters have always been a favorite among gamers. Their simplicity, fast-paced action, and pixel-art aesthetics create an engaging experience. I set out to create my own Pixel Shooter Game using p5.js, adding dynamic enemy interactions, ammo management, health pickups, and immersive sound effects.

The idea was simple:

  • The player moves around the screen and shoots at approaching enemies.
  • The enemies chase the player and deal damage upon collision.
  • The player must manage ammo and collect pickups to survive.
  • The game ends when the player’s health reaches zero.

With these mechanics in mind, I designed a game that combines action, strategy, and survival elements.

I used a lot of sound effects from popular shooter games (Half Life, Counter Strike, some soundtracks from Red Faction II) and sandbox games like Minecraft.

I also used free, open-source sprites from different forums, God bless open-source!

How It Works

The game follows a state-based system, transitioning between:

  1. Loading Screen – Displays a progress bar while assets are loading.
  2. Start Menu – Shows the title, “Start Game” and “Instructions” buttons.
  3. Instructions Page – Displays movement, shooting, and gameplay tips.
  4. Play Mode – The core gameplay where enemies chase the player.
  5. Game Over Screen – Displays the final score and an option to restart.

Core Mechanics

  • Player Movement: Uses WASD or arrow keys to move.
  • Shooting: Press Space to fire bullets.
  • Enemy AI: Enemies spawn and move toward the player.
  • Health System: The player starts with 3 HP and dies after three enemy hits.
  • Ammo System: The player has 20 bullets max and must pick up ammo to reload.
  • Pickups: Random enemies drop ammo or health packs.

Code Highlights:

1) State-Based Game Flow

To keep the game organized, I used a state-based approach to control transitions:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function draw() {
background(20);
if (gameState === "loading") {
showLoadingScreen();
if (assetsLoaded >= totalAssets && loadingCompleteTime === 0) {
loadingCompleteTime = millis();
}
if (loadingCompleteTime > 0 && millis() - loadingCompleteTime > loadingDelay) {
gameState = "start";
}
}
else if (gameState === "start") {
showStartScreen();
startButton.show();
instructionsButton.show();
backButton.hide(); // Ensure Back button is hidden
if (!startMusic.isPlaying()) {
startMusic.loop();
startMusic.setVolume(0.5);
}
}
else if (gameState === "instructions") {
showInstructionsScreen();
startButton.hide();
instructionsButton.hide();
backButton.show();
}
else if (gameState === "play") {
runGame();
showScore();
startButton.hide();
instructionsButton.hide();
backButton.hide();
if (startMusic.isPlaying()) {
startMusic.stop();
}
if (!gameMusic.isPlaying()) {
gameMusic.loop();
gameMusic.setVolume(0.5);
}
}
else if (gameState === "dead") {
showDeadScreen();
startButton.hide();
instructionsButton.hide();
backButton.hide();
}
}
function draw() { background(20); if (gameState === "loading") { showLoadingScreen(); if (assetsLoaded >= totalAssets && loadingCompleteTime === 0) { loadingCompleteTime = millis(); } if (loadingCompleteTime > 0 && millis() - loadingCompleteTime > loadingDelay) { gameState = "start"; } } else if (gameState === "start") { showStartScreen(); startButton.show(); instructionsButton.show(); backButton.hide(); // Ensure Back button is hidden if (!startMusic.isPlaying()) { startMusic.loop(); startMusic.setVolume(0.5); } } else if (gameState === "instructions") { showInstructionsScreen(); startButton.hide(); instructionsButton.hide(); backButton.show(); } else if (gameState === "play") { runGame(); showScore(); startButton.hide(); instructionsButton.hide(); backButton.hide(); if (startMusic.isPlaying()) { startMusic.stop(); } if (!gameMusic.isPlaying()) { gameMusic.loop(); gameMusic.setVolume(0.5); } } else if (gameState === "dead") { showDeadScreen(); startButton.hide(); instructionsButton.hide(); backButton.hide(); } }
function draw() {
  background(20);

  if (gameState === "loading") {
    showLoadingScreen();
    if (assetsLoaded >= totalAssets && loadingCompleteTime === 0) {
      loadingCompleteTime = millis();
    }
    if (loadingCompleteTime > 0 && millis() - loadingCompleteTime > loadingDelay) {
      gameState = "start";
    }
  } 
  else if (gameState === "start") {
    showStartScreen();
    startButton.show();
    instructionsButton.show();
    backButton.hide(); // Ensure Back button is hidden
    
    if (!startMusic.isPlaying()) {
      startMusic.loop();
      startMusic.setVolume(0.5);
    }
  } 
  else if (gameState === "instructions") {
    showInstructionsScreen();
    startButton.hide();
    instructionsButton.hide();
    backButton.show();
  } 
  else if (gameState === "play") {
    runGame();
    showScore();
    startButton.hide();
    instructionsButton.hide();
    backButton.hide();
    
    if (startMusic.isPlaying()) {
      startMusic.stop();
    }
    
    if (!gameMusic.isPlaying()) {
      gameMusic.loop();
      gameMusic.setVolume(0.5);
    }
  } 
  else if (gameState === "dead") {
    showDeadScreen();
    startButton.hide();
    instructionsButton.hide();
    backButton.hide();
  }
}

Each function (showLoadingScreen(), runGame(), etc.) controls what is displayed based on the current state.


2) Player Shooting Mechanic

The player can shoot bullets, but with an ammo limit:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Player {
constructor() {
this.pos = createVector(width / 2, height / 2);
this.speed = 4;
this.movementDist = 0; // we update this in update()
this.ammo = 20;
this.health = 3;
this.lastDamageTime = 0; // Track last time the player took damage
}
update() {
...
}
show() {
push();
translate(this.pos.x, this.pos.y);
// Face the mouse (common in top-down shooters).
let angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x);
rotate(angle);
scale(0.25);
// Decide idle or move frames
let moving = (this.movementDist > 0.1);
// Pick frame index
let frameIndex;
imageMode(CENTER);
if (moving) {
frameIndex = floor(frameCount / 6) % playerMoveSprites.length;
image(playerMoveSprites[frameIndex], 0, 0);
} else {
frameIndex = floor(frameCount / 12) % playerIdleSprites.length;
image(playerIdleSprites[frameIndex], 0, 0);
}
pop();
this.showHealthBar();
}
showHealthBar() {
...
}
shoot() {
// Fire only if enough time has passed since last shot AND we have ammo left
if (this.ammo > 0 && millis() - lastShotTime > 200) {
bullets.push(new Bullet(this.pos.x, this.pos.y));
lastShotTime = millis();
this.ammo--; // reduce ammo by 1
}
if (gunSound)
gunSound.play();
}
}
class Player { constructor() { this.pos = createVector(width / 2, height / 2); this.speed = 4; this.movementDist = 0; // we update this in update() this.ammo = 20; this.health = 3; this.lastDamageTime = 0; // Track last time the player took damage } update() { ... } show() { push(); translate(this.pos.x, this.pos.y); // Face the mouse (common in top-down shooters). let angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x); rotate(angle); scale(0.25); // Decide idle or move frames let moving = (this.movementDist > 0.1); // Pick frame index let frameIndex; imageMode(CENTER); if (moving) { frameIndex = floor(frameCount / 6) % playerMoveSprites.length; image(playerMoveSprites[frameIndex], 0, 0); } else { frameIndex = floor(frameCount / 12) % playerIdleSprites.length; image(playerIdleSprites[frameIndex], 0, 0); } pop(); this.showHealthBar(); } showHealthBar() { ... } shoot() { // Fire only if enough time has passed since last shot AND we have ammo left if (this.ammo > 0 && millis() - lastShotTime > 200) { bullets.push(new Bullet(this.pos.x, this.pos.y)); lastShotTime = millis(); this.ammo--; // reduce ammo by 1 } if (gunSound) gunSound.play(); } }
class Player {
  constructor() {
    this.pos = createVector(width / 2, height / 2);
    this.speed = 4;
    this.movementDist = 0; // we update this in update()
    this.ammo = 20;  
    this.health = 3;
    this.lastDamageTime = 0;  // Track last time the player took damage


  }

  update() {
   ...
  }

  show() {
    push();
    translate(this.pos.x, this.pos.y);

    // Face the mouse (common in top-down shooters).
    let angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x);
    rotate(angle);
    scale(0.25);


    // Decide idle or move frames
    let moving = (this.movementDist > 0.1);

    // Pick frame index
    let frameIndex;
      imageMode(CENTER);

    if (moving) {
      frameIndex = floor(frameCount / 6) % playerMoveSprites.length;
      image(playerMoveSprites[frameIndex], 0, 0);
    } else {
      frameIndex = floor(frameCount / 12) % playerIdleSprites.length; 
      image(playerIdleSprites[frameIndex], 0, 0);
    }

    pop();
    
    this.showHealthBar();

  }
  
    showHealthBar() {
    ...
  }

  shoot() {
    // Fire only if enough time has passed since last shot AND we have ammo left
    if (this.ammo > 0 && millis() - lastShotTime > 200) {
      bullets.push(new Bullet(this.pos.x, this.pos.y));
      lastShotTime = millis();
      this.ammo--;  // reduce ammo by 1
    }
    
      if (gunSound) 
        gunSound.play();
  }
}

This ensures players can’t spam bullets, adding a strategic element to gameplay.

3) Enemy AI and Collision Handling

Enemies move toward the player, and if they collide, the player takes damage:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Enemy-player collisions (Fixed)
for (let enemy of enemies) {
let dPlayer = dist(player.pos.x, player.pos.y, enemy.pos.x, enemy.pos.y);
// Only take damage if enough time has passed (e.g., 1 second)
if (dPlayer < 30 && millis() - player.lastDamageTime > 1000) {
player.health--;
player.lastDamageTime = millis(); // Update last hit time
if (hitSound) {
hitSound.play();
}
if (player.health <= 0) {
if(playerDeathSound) {
playerDeathSound.play();
}
gameState = 'dead';
}
}
}
// Enemy-player collisions (Fixed) for (let enemy of enemies) { let dPlayer = dist(player.pos.x, player.pos.y, enemy.pos.x, enemy.pos.y); // Only take damage if enough time has passed (e.g., 1 second) if (dPlayer < 30 && millis() - player.lastDamageTime > 1000) { player.health--; player.lastDamageTime = millis(); // Update last hit time if (hitSound) { hitSound.play(); } if (player.health <= 0) { if(playerDeathSound) { playerDeathSound.play(); } gameState = 'dead'; } } }
// Enemy-player collisions (Fixed)
for (let enemy of enemies) {
  let dPlayer = dist(player.pos.x, player.pos.y, enemy.pos.x, enemy.pos.y);
  
  // Only take damage if enough time has passed (e.g., 1 second)
  if (dPlayer < 30 && millis() - player.lastDamageTime > 1000) {
    player.health--;  
    player.lastDamageTime = millis(); // Update last hit time
    
    if (hitSound) {
      hitSound.play();
    }
    
    if (player.health <= 0) {
      if(playerDeathSound) {
        playerDeathSound.play();
      }
      gameState = 'dead';
    }
  }
}

This prevents instant death by implementing a damage cooldown.

4) Dynamic Background Music

Different background tracks play in menu and play modes:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
else if (gameState === "play") {
runGame();
showScore();
startButton.hide();
instructionsButton.hide();
backButton.hide();
if (startMusic.isPlaying()) {
startMusic.stop();
}
if (!gameMusic.isPlaying()) {
gameMusic.loop();
gameMusic.setVolume(0.5);
}
}
else if (gameState === "play") { runGame(); showScore(); startButton.hide(); instructionsButton.hide(); backButton.hide(); if (startMusic.isPlaying()) { startMusic.stop(); } if (!gameMusic.isPlaying()) { gameMusic.loop(); gameMusic.setVolume(0.5); } }
else if (gameState === "play") {
  runGame();
  showScore();
  startButton.hide();
  instructionsButton.hide();
  backButton.hide();
  
  if (startMusic.isPlaying()) {
    startMusic.stop();
  }
  
  if (!gameMusic.isPlaying()) {
    gameMusic.loop();
    gameMusic.setVolume(0.5);
  }
}

This immerses the player by dynamically switching music.

Challenges and Improvements

1) Handling Enemy Collisions Fairly

Initially, enemies instantly killed the player if they were touching them. To fix this, I added a damage cooldown, so players have 1 second of immunity between hits.

2) Centering Buttons Properly

Buttons were misaligned when resizing the canvas. Instead of manually placing them, I used dynamic centering.

3) Preventing Accidental Game Starts

Initially, pressing any key started the game. To fix this, I made keyPressed() work only in play mode.

Final Thoughts

This Pixel Shooter Game was a fun challenge that combined:

  • Game physics (enemy movement, shooting mechanics)
  • User experience improvements (better UI, centered buttons)
  • Audio immersion (different music for each state)
  • Optimization tricks (cropping backgrounds, limiting bullet spam)

Possible Future Improvements

  • Add power-ups (e.g., speed boost, rapid fire)
  • Implement different enemy types
  • Introduce a high score system
  • Introduce multiplayer using socket.io (websocket server connection, so two different clients could play on separate machines)

This project demonstrates how p5.js can create interactive, engaging 2D games while keeping code structured and scalable.

Sketch (Click Here to Open Full Screen)

 

Midterm-gardens of butterflies

For this midterm project I chose to create a game of a garden filled with butterflies, nature-inspired environment where colorful butterflies flutter randomly across the screen. I used the spacebar to control a net that follows the cursor, to catch the butterflies making the gameplay intuitive and engaging.

The goal of the game is to catch as many butterflies as possible . Each butterfly has different colors and sizes, . I also added gentle ambient sounds, like a net sound, to create a soothing experience.

in conclusion , I aimed to create a beautiful digital garden that tests hand-eye coordination while offering a calming escape.

Future Improvements:
I believe the game could be further enhanced by adding different levels of difficulty. For example, introducing obstacles or making some butterflies faster and more evasive would add a layer of challenge. I also think that implementing a scoring system with a timer could increase replayability. Another idea is to introduce special butterflies that grant power-ups, like slowing down all butterflies or extending the catch radius of the net. Additionally, I would explore adding dynamic weather effects, such as changing skies or light rain, to create a more immersive environment. Ultimately, my goal is to refine the game by incorporating feedback and exploring new ways to engage players through both gameplay and visuals.

code im proud of:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class GameMenuDisplay {
show() {
butterflyMenu.move();
butterflyMenu.show();
noStroke();
fill(255, 255, 255, 200);
rect(420, 20, 360, 560);
// How To Play Text
textSize(32);
textAlign(CENTER, CENTER);
fill(0);
text("Catch The Butterfly", 600, 60);
textSize(20);
text("Game Instruction: ", 600, 220);
textSize(14);
text("Move the net with your mouse and\npress SPACE BAR to catch the butterflies.", 600, 280);
textAlign(CENTER, CENTER);
text("Click anywhere to start the game.", 600, 520);
}
}
class GameMenuDisplay { show() { butterflyMenu.move(); butterflyMenu.show(); noStroke(); fill(255, 255, 255, 200); rect(420, 20, 360, 560); // How To Play Text textSize(32); textAlign(CENTER, CENTER); fill(0); text("Catch The Butterfly", 600, 60); textSize(20); text("Game Instruction: ", 600, 220); textSize(14); text("Move the net with your mouse and\npress SPACE BAR to catch the butterflies.", 600, 280); textAlign(CENTER, CENTER); text("Click anywhere to start the game.", 600, 520); } }
class GameMenuDisplay {
    show() {
        butterflyMenu.move();
        butterflyMenu.show();

        noStroke();
        fill(255, 255, 255, 200);
        rect(420, 20, 360, 560);

        // How To Play Text
        textSize(32);
        textAlign(CENTER, CENTER);
        fill(0);
        text("Catch The Butterfly", 600, 60);
        
        textSize(20);
        text("Game Instruction: ", 600, 220);
        
        textSize(14);
        text("Move the net with your mouse and\npress SPACE BAR to catch the butterflies.", 600, 280);

        textAlign(CENTER, CENTER);
        text("Click anywhere to start the game.", 600, 520);
    }
}