Creating a game using p5.js and Jutsu on BOS

Overview of Project

In this article I will be using jutsu.ai as my IDE, and p5.js to make a simple game where users can drop a ball of a random color and size, balls of the same size and color will combine and increase the players score.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Technologies

Jutsu.ai - Jutsu is a developer platform designed to help you build, launch, and host decentralized frontends.

p5.js - p5.js a JS client-side library for creating graphic and interactive experiences, based on the core principles of Processing.

Requirements

Knowledge of HTML, CSS, JavaScript and React Components.

Getting Started

Head over to Jutsu.ai, login using your preferred account and create a new component.

Let's start by creating a react component that we will use to return the game using and iframe, here is the initial set up for that.

const GameContainer = () => {
  const code = ``;

  return (
    <div
      style={{ height: "80%", display: "flex", flexDirection: "column" }}
      className="mx-auto"
    >
      <iframe className="w-100 h-100" srcDoc={code} />
    </div>
  );
};

return  <div style={{ width: "100%", height: "100%", background: "blue" }}>
    <GameContainer />
  </div>;

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Using this set up we can tell the difference between the screen and the game screen, at the moment the iframe is loading nothing so the entire screen is blue, let's add some code to get the game screen to show through the Iframe, this maybe a bit uncomfortable for some people but bare with me, what we are going to do is write all the code inside the code variable and pass it into the Iframe as an ES6 Template Literal String.

Any code written inside the code variable has to use HTML syntax, for this example the head, style, body, and script tags will be used.

Using this method we are going to load some a font from google font, then using p5.js create a canvas to display the game, we will load the font and the p5.js library from the head tag, and any code related to the game will be inside the script tag.

const GameContainer = () => {
  const code = `
    <head>
<link href="https://fonts.googleapis.com/css2?family=Lugrasimo&family=VT323&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.js" integrity="sha512-2r+xZ/Dm8+HI0I8dsj1Jlfchv4O3DGfWbqRalmSGtgdbVQrZyGRqHp9ek8GKk1x8w01JsmDZRrJZ4DzgXkAU+g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>
<style>
   body {
   margin: 0;
   display: flex;
   align-items: center;
   flex-direction: column;
   overflow: hidden;
   }

</style>
<body>
</body>
<script>

   function setup() {
      createCanvas(350, windowHeight);
    }
   
    function draw() {
      background(220)

    }
   
    function mouseClicked() {

    }

    class Circle {

    }
   

</script>
  `;

  return (
    <div
      style={{
        width: "100%",
        height: "80%",
        display: "flex",
        flexDirection: "column",
      }}
      className="mx-auto"
    >
      <iframe className="w-100 h-100" srcDoc={code} />
    </div>
  );
};

return (
  <div style={{ width: "100%", height: "100%", background: "blue" }}>
    <GameContainer />
  </div>
);

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Once everything has loaded correctly you should be able to create a canvas with a desired width and height using createCanvas() inside a setup function, these values could be fixed or if you want them to be dynamic with it's parent container then you could use windowWidth and windowHeight to do so, inside the draw function you can also use the background(r,g,b) function, which accepts an R, G, or B value for each parameter, to color the background of the canvas.

Now lets create a circle at the mouses position on the gamescreen whenever someone clicks, to do this we will create a constructor inside the Circle class which will define an X and Y position, a radius, and a color for the circle, we will also define a display() function for the circle which will make the circle appear on the screen when called.

Inside the mouseClicked function and other p5.js functions you have access to the mouseX and mouseY variables which are your current cursors position on the canvas, so using those as our circle's x and y position and the color red using RGB ({r:255, g:0, b:0}) we can call circle.display() inside the draw() function.

<script>

  let circle = null;

   function setup() {
      createCanvas(350, windowHeight);
    }
   
    function draw() {
      background(220)

      if(circle){
        circle.display();
      }

    }
   
    function mouseClicked() {
      circle = new Circle(mouseX, mouseY, {r: 255, g: 0, b: 0})
    }

    class Circle {
       constructor(x, y, color) {
        this.x = x;
        this.y = y;
        this.radius = 25;
        this.color = color;
      }

      
   
      display() {
        fill(this.color.r, this.color.g, this.color.b);
        ellipse(this.x, this.y, this.radius * 2);
      }

    }
   

</script>

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Now lets add some logic to have the circle fall after you click and bounce off the ground or wall, to do that we need to add a few more properties to the circle:

  • isFalling: keeps track of falling state.
  • xSpeed and ySpeed: how much the circle moves on that axis.

Let's create a new global varaible called groundHeight, this will define where the ground is on the screen, we will also create a fall() and checkBounds() function, fall() checks if a circle is falling then simulates gravity by increasing it's Y position by the ySpeed when called, checkBounds() ensures that a circle will never cross the set groundHeight and the wall. After creating a circle, setting it's isFalling value to true and calling circle.fall() and circle.checkBounds() after circle.display() should make the circle fall and hit the floor.

  let circle = null;
  let groundHeight;

   function setup() {
      createCanvas(350, windowHeight);
      groundHeight = height; // Ground position
    }
   
    function draw() {
      background(220)

      if(circle){
        circle.display();
        circle.fall();
        circle.checkBounds();
      }

    }
   
    function mouseClicked() {
      circle = new Circle(mouseX, mouseY, {r: 255, g: 0, b: 0})
      circle.isFalling = true;
    }

    class Circle {
       constructor(x, y, color) {
        this.x = x;
        this.y = y;
        this.radius = 25;
        this.color = color;
        this.isFalling = false;
        this.xSpeed = 0;
        this.ySpeed = 0;
      }

      
   
      display() {
        fill(this.color.r, this.color.g, this.color.b);
        ellipse(this.x, this.y, this.radius * 2);
      }

      fall() {
        if (this.isFalling) {
          this.y += this.ySpeed;
          this.ySpeed += 0.2; // Simulate gravity
          this.x += this.xSpeed;
          }
      }

      checkBounds() {
        if (this.x - this.radius < 0 || this.x + this.radius > width) {
          this.x = constrain(this.x, this.radius, width - this.radius);

            // Bounce off walls
          if (this.x + this.radius >= width || this.x - this.radius <= 0) {
            this.xSpeed *= -0.1; // Reduce x speed upon wall impact
          }
        }
        if (this.y - this.radius < 0 || this.y + this.radius > height) {
          this.y = constrain(this.y, this.radius, height - this.radius);

          // Check for hitting the ground
          if (this.y + this.radius >= groundHeight) {
            this.y = groundHeight - this.radius;
            this.ySpeed *= -0.1; // Reduce y speed upon ground impact (dampening)
            this.xSpeed *= 0.6; // Reduce x speed upon ground impact
          }

        }
      }

    }
   

ezgif.com-video-to-gif-converted (1)

Let's add some logic to the circle that should allow the player to fill the screen with circles that can stack ontop each other. First we will need to add a new global variable, an Array that will store all the circles dropped by the player, we also need to make some adjustments to how a circle a drawn to the screen.

At the moment inside the draw function we check to see if the circle variable gets updated and if it is then we call display, fall, and checkBounds on that circle, what we are going to do now is loop through the array of circles dropped by the player and call display, fall, and checkBounds on those circles.

Next would be to add two functions, a function that handles circles bouncing off each other and another to check and handle if circles collide with each other, to accomplish this we will be using the following p5.js functions:

  • sqrt() - Calculates the square root of a number.
  • cos() - Calculates the cosine of an angle.
  • sin() - Calculates the sine of an angle.
  • atan2() - Calculates the angle formed by a specified point, the origin, and the positive x-axis.

For now we just want the circles to bounce off each other and to accomplish that we will use the following function:

handleCircleCollision(otherCircle) {
    // Calculate the differences in x and y positions between the circles
    let dx = this.x - otherCircle.x;
    let dy = this.y - otherCircle.y;

    // Calculate the squared distance between the centers of the circles
    let distanceSquared = dx * dx + dy * dy;

    // Calculate the squared minimum distance at which the circles should collide
    let minDistanceSquared = (this.radius + otherCircle.radius) * (this.radius + otherCircle.radius);

    // Check if the squared distance is less than or equal to the squared minimum distance
    if (distanceSquared <= minDistanceSquared) {
        // If the circles are colliding, resolve the collision between them
        this.handleCollisionBounce(otherCircle);
    }
}

To handle having the circles bounce off each other we will be using this function:

handleCollisionBounce(other) {
    // Calculate the difference in positions between the two circles
    let dx = other.x - this.x;
    let dy = other.y - this.y;
    let distance = sqrt(dx * dx + dy * dy); // Calculate the distance between the circles

    // Calculate the minimum distance needed to separate the circles without overlap
    let minDistance = this.radius + other.radius;
    let separationX = dx / distance * (minDistance - distance);
    let separationY = dy / distance * (minDistance - distance);

    // Move circles apart to prevent overlap
    this.x -= separationX / 2;
    this.y -= separationY / 2;
    other.x += separationX / 2;
    other.y += separationY / 2;

    // Calculate collision angles and speeds for bouncing effect
    let angle = atan2(dy, dx); // Calculate angle of collision
    let thisSpeed = sqrt(this.xSpeed * this.xSpeed + this.ySpeed * this.ySpeed); // Calculate speed of this circle
    let otherSpeed = sqrt(other.xSpeed * other.xSpeed + other.ySpeed * other.ySpeed); // Calculate speed of other circle
    let thisDirection = atan2(this.ySpeed, this.xSpeed); // Calculate direction of this circle's movement
    let otherDirection = atan2(other.ySpeed, other.xSpeed); // Calculate direction of other circle's movement

    // Calculate new velocities after collision
    let newThisXSpeed = otherSpeed * cos(otherDirection - angle) * cos(angle) + (thisSpeed) * sin(thisDirection - angle) * cos(angle + HALF_PI);
    let newThisYSpeed = otherSpeed * cos(otherDirection - angle) * sin(angle) + (thisSpeed) * sin(thisDirection - angle) * sin(angle + HALF_PI);
    let newOtherXSpeed = (thisSpeed) * cos(thisDirection - angle) * cos(angle) + otherSpeed * sin(otherDirection - angle) * cos(angle + HALF_PI);
    let newOtherYSpeed = (thisSpeed) * cos(thisDirection - angle) * sin(angle) + otherSpeed * sin(otherDirection - angle) * sin(angle + HALF_PI);

    // Update velocities to reflect the new directions and speeds after collision
    this.xSpeed = newThisXSpeed;
    this.ySpeed = newThisYSpeed;
    other.xSpeed = newOtherXSpeed;
    other.ySpeed = newOtherYSpeed;
}

Now with these functions we can have each ball check if it is colliding with another ball and handle it accordingly, to do this we will have each circle check if it is colliding with the next circle in the array, we can accomplish that by doing the following:

function draw() {
    background(220);

    for (let i = 0; i < gameCircles.length; i++) {
        gameCircles[i].display();
        gameCircles[i].fall();
        gameCircles[i].checkBounds();

        for (let j = i + 1; j < gameCircles.length; j++) {
            gameCircles[i].handleCircleCollision(gameCircles[j]);
        }
    }
}

If everything was done correctly you should have something like this.

ezgif.com-video-to-gif-converted (4)

Next, let's add some logic to allow circles of similar color and radius to merge with each other, to accomplish this we will add an if statement inside the handleCircleCollision function to check if both circles are the same color and radius if so merge the two circles. To get the merge effect we want we will create a new radius by combining the raidus of both circles then assign that new radius to one of the two circles and remove the other circle from the array to have it vanish from the game container.

handleCircleCollision(otherCircle) {
    // Calculate the differences in x and y positions between the circles
    let dx = this.x - otherCircle.x;
    let dy = this.y - otherCircle.y;

    // Calculate the squared distance between the centers of the circles
    let distanceSquared = dx * dx + dy * dy;

    // Calculate the squared minimum distance at which the circles should collide
    let minDistanceSquared = (this.radius + otherCircle.radius) * (this.radius + otherCircle.radius);

    // Check if the squared distance is less than or equal to the squared minimum distance
    if (distanceSquared <= minDistanceSquared) {
        if (
            this.color.r === otherCircle.color.r &&
            this.color.g === otherCircle.color.g &&
            this.color.b === otherCircle.color.b &&
            this.radius === otherCircle.radius
        ) {
            // Merge circles if they touch and have similar color and radius
            let newRadius = this.radius + otherCircle.radius;
            this.radius = newRadius;
            otherCircle.radius = 0;
            gameCircles = gameCircles.filter(circle => circle !== otherCircle);

            if (newRadius === 100) {
                // Remove circles if the merged radius reaches 100
                gameCircles = gameCircles.filter(circle => circle !== this && circle !== otherCircle);
            }
        } else {
            // If the circles are colliding, resolve the collision between them
            this.handleCollisionBounce(otherCircle);
        }
    }
}

Here is the modified handleCircleCollision function with an additional if statement that removes both circles if the new radius is equal to 100. Next let's implement a random size and color for each circle that is created, we will initialize two new global varibles one that will hold the sizes we want and another to hold the colors.

   const sizes = [10, 20, 40];
   let circleColors = [{r: 255, g: 0, b: 0},{r: 0, g: 255, b: 0},{r: 0, g: 0, b: 255}]

Now inside the mouseClicked function we will generate a random number using the built in Math.floor and Math.random functions, then using that number we will grab a size and color from their respected arrays and assign them to the new circle.

function mouseClicked() {
    const randPos = Math.floor(Math.random() * sizes.length);
    
    let newCircle = new Circle(mouseX, mouseY, circleColors[randPos]);
    newCircle.radius = sizes[randPos];
    newCircle.isFalling = true;
    gameCircles.push(newCircle);
}

Next let's add a timer that will prevent the player from creating anymore circles after the timer hits 0, and a score that increases everytime a merge occurs. First we need to add two new global variables one for the timer and the othe for the score.

    let gameScore = 0;
    let timer = 30;

Once the game starts we want the timer to start, so to trigger the timer to start counting down we will use a setInterval to decrease the timer by 1 every second (1000ms) until it hits 0.

function setup() {
    createCanvas(350, windowHeight);
    groundHeight = height; // Ground position

    setInterval(() => {
        if (timer > 0) {
            timer--;
        }
    }, 1000);
}

To stop the player from creating circles once the timer the timer hits 0 we are going to add an if statement inside the mouseClicked function that checks if the timer is greater than 0, if so then the player can create a circle.

function mouseClicked() {
    if (timer > 0) {
        const randPos = Math.floor(Math.random() * sizes.length);
        
        let newCircle = new Circle(mouseX, mouseY, circleColors[randPos]);
        newCircle.radius = sizes[randPos];
        newCircle.isFalling = true;
        gameCircles.push(newCircle);
    }
}

Now lets display the timer and score to the screen using the font we imported earlier, we are going to use the following p5.js functions to accomplish this inside of the draw function.

  • textFont() - Sets the font used by the text() function.
  • textSize() - Sets the font size when text() is called. Font size is measured in pixels.
  • fill() - Sets the color used to fill shapes. (in this specific use case it will be used to set the text color even though it says it sets the color for shapes.)
  • text() - Draws text to the canvas.The first parameter, string, is the text to be drawn. The second and third parameters, x and y, set the coordinates.
function draw() {
    background(220);
    textFont("VT323");

    textSize(30);
    fill(0);
    text("Time: " + timer, 250, 20);

    textSize(30);
    fill(0);
    text("Score: " + gameScore, 20, 20);

    for (let i = 0; i < gameCircles.length; i++) {
        gameCircles[i].display();
        gameCircles[i].fall();
        gameCircles[i].checkBounds();
        for (let j = i + 1; j < gameCircles.length; j++) {
            gameCircles[i].handleCircleCollision(gameCircles[j]);
        }
    }
}

Finally let's add the logic to increase the score everytime a merge occurs, for this example we will have each regular merge increase the score by 5 and any pop (both circles vanishing) be worth 10, this is a small change to the if statement that makes circles vanish when they reach a certain radius.

if (newRadius > 100) {
    gameScore += 10;
    gameCircles = gameCircles.filter(circle => circle !== this && circle !== otherCircle);
} else {
    gameScore += 5;
}

If everything went well then you should have something that looks and works like this.

ezgif.com-video-to-gif-converted (6)

So that the game copies the same mechanics as the one shown in the picture at the beginnning we will have the ball fall from a specified height on the screen, all you need to to is specify a fixed Y value for the circle when it's created instead of the mouseY value which updates everytime the mouse moves.

let newCircle = new Circle(mouseX, 50, circleColors[randPos]);

// Feel free to play around with that value to get a height you are comfortable with