DEV Community

Cover image for Game Building with JavaScript
nicklevenson
nicklevenson

Posted on • Edited on

Game Building with JavaScript

For my latest coding project I decided to build a simple game using vanilla JavaScript, CSS, HTML, and a Ruby on Rails backend to keep track of user data. The game would be straight forward, throw a paper plane at a target and score points. Some topics I'll cover in this article HTML Canvas and JS, Rails as an API, and fetching data with JS.

Play the game
View the code
Video demo

Canvas
I started the project by developing the game functionality. I wanted to have a game consist of 10 rounds, each throwing the plane at a target. I used the HTML element Canvas as my animation medium, and manipulated all the data with JS.

First thing is first, I placed a canvas object in my HTML doc. When a round starts, we will access this object and go from there. Below is the code where we get elements from our HTML, prepare our canvas, and the objects we will animate. Be sure to follow along with the comments. There was a lot to this part of the project, so I can't cover it all in this article, but I encourage you to study the github code if you're interested and want to dive deeper.

//lets grab these elements. We need to grab the slide for the power, and the canvas itself.

      const slideContainer = document.getElementById("speedSlider")
      const slide = document.getElementById("slide")
      let canvas = document.getElementById("myCanvas");

//sizing

//This is where we set the canvas size. 
//I wanted to base it on the current screen's height and width so its responsive.
//For objects within the canvas, we will set heights and widths relative to the canvas. 

      canvas.height = (screen.height * .5)
      canvas.width = canvas.height * .75 - 100
      leaderboardContainer.style.height = canvas.height + "px"
      gameStats.style.width = canvas.width + "px"

//plane sizing
      let planeW = canvas.height * .05;
      let planeH = planeW * 1.25;
//target sizing
      let targetW = canvas.height * .125;
      let targetH = targetW;
//size of power slide
      slideContainer.style.height = (canvas.height) + "px"
      let slideH = slideContainer.offsetHeight
//size of the wind arrow object
      let windW = 25
      let windH = 50
//Here we set ctx to the inner context of the Canvas. 
//We will use ctx from here to control actions within the canvas. 
//Transform allows us to flip the canvas Y axis to be more intuitive from its original orientation

      let ctx = canvas.getContext("2d");
      ctx.transform(1, 0, 0, -1, 0, canvas.height)

//lastly, we will set initial coordinates (x,y) for the plane. The plane will always follow these coordinates. 
      let x = canvas.width/2;
      let y = 30;
//dx and dy are what we will use to give the plane a trajectory or (velocity). They will start at 0 since we aren't moving the plane yet.
      let dx = 0;
      let dy = 0;
//angle will be the initial angle of the plane with a direction set to 'right' this will be used when we animate the angle of the plane
      let angle = 0
      let direction = "right"

//calibration
   //I won't go into this much since it is fairly complicated, but we are essentially setting the wind power, and the gravity.
      //negative wind is a n || e. positive wind is s || w 
      let windY = getWind().y
      let windX = getWind().x
      // let windY = 0
      // let windX = 0
      let windDirection = getWindDirection()
      let windAngle = getWindAngle()
      // let windPower = (((Math.abs(windY) * Math.abs(windX))+1)* 10).toPrecision(3)
      let windPower = ((Math.sqrt((Math.abs((windX*100)**2)) + (Math.abs((windY*100)**2))))).toPrecision(3)

      let power = 0

//we set the gravity to the height of the canvas. This will limit out plane's flight.
      let gravity = canvas.height

Enter fullscreen mode Exit fullscreen mode

Phew, that was a lot of sizing. Now we have the sizes of everything set relative to the canvas - which is set relative to the viewport. Now we need to start drawing and implementing some logic. First the plane needs to iterate through different angles for the user to then pick an angle for the flight.

//lets start a round
 function startRound() {
//control is the button that users press to control everything
        control.innerText = "Angle..."
//lets call the drawing function that angles the plane. We use setInterval() to create animation frames. 
        anglage = setInterval(moveAnglePlane, 50);
//When the user clicks the angle, we clear the angle animation and trigger the power slider animation.
        control.addEventListener('click', function space(e){
            control.innerText = "Power..."
            clearInterval(anglage)
            sliderLoop()
            startSlide()
            control.removeEventListener("click", space);
        })
      }
Enter fullscreen mode Exit fullscreen mode

Okay, let's jump to the part where we are actually animating the angle choice on the canvas. This is the function that we just set an interval on. Animating things in Canvas requires us to draw and redraw everything in the canvas every single frame, kind of like a spool of film or stop motion animation.

 function rotatePlane() {
//we draw the target, wind, and wind power text on the canvas every screen since each animation frame in the canvas is completely cleared and redrawn.
      drawTarget()
      drawWind()
      drawWindPower()
//we use translate to orient the plane's anchor to its x and y coordinates from before
      ctx.translate(x, y);
//we use rotate to angle the plane to its set angle variable from before
      ctx.rotate(angle);

 //drawImage is a canvas function to draw on an image asset (the plane in this case)
      ctx.drawImage(img,-(planeW/2),0,planeW,planeH)
//the logic below allows the plane to change its angle direction if it hits a certain angle. This provides us with our cycling of angles in the game for the user to choose from.
      if (angle >= 1.5) {
        direction = "left"
      }   
      if (angle <= -1.5) {
        direction = "right"
      }
//our anglePlane function essentially executes every frame, adding or subtracting a tiny amount from the angle based on the direction it is currently in. 
    angle = anglePlane(angle, direction)
    }

//this is the actual function we called with our setInterval in the startRound function. This just clears the canvas, saves it, and draws the plane's rotation (with the rotatePlane()). 
    function moveAnglePlane() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      rotatePlane()
      ctx.restore();
    }
    function anglePlane(angle, direction) {
      if (direction === "right") {
        return angle + 1/10
      }
      if (direction === "left") {
        return angle - 1/10
      }
    }
Enter fullscreen mode Exit fullscreen mode

Ok, the last set of functions allows the plane angle to cycle through and allow it to be chosen by the user on a click. Once it is clicked, we start the power slider - we call the sliderLoop() function. This function isn't shown here, but it essentially animates the power bar for the user to choose power. startSlide() is also called after we chose the angle. This function just sets the control bar to listen for a click and execute some other functions - most importantly, moving the plane forward.

function startSlide() {
        control.addEventListener('click', function space(e){
            control.innerText = "Throw!"
            control.style.backgroundColor = "grey"
//stop the power bar loop
            clearTimeout(doSlide)
//play a sound
            woosh.play()
//call the movePlane() function we will see below.
            movePlane() 
//add to the throw count in the document
            throwCount ++
            throwCountTitle.innerText = `(${throwCount} throws and counting!)`
//lastly removing the event listener from being triggered again.
            control.removeEventListener("click", space);
        })
      }
Enter fullscreen mode Exit fullscreen mode

Now we will get into the actual animation of moving the plane forward. This required some math that I hadn't used since high school. Namely pythagorean theorem...

  function movePlane() {
//getTrajectory gets an X and Y target value based on the angle of the plane and the power that the user chose. See it at the bottom of this block. Thats the pythagorean happening.
      let XY = getTrajectory()
//with out XY object we now have to check the angle of the plane, check if its going left or right of the center of the canvas. 
//We then set our dx and dy variables to these values added/subtracted with our current x,y location.
      if (angle >= 0) {
        dx = Math.round(x - XY.x)
        dy = Math.round(y + XY.y)

      }else{
        dx = Math.round(x + XY.x)
        dy = Math.round(y + XY.y)
      }
      //now we set an animation function interval to loop.
      anglage = setInterval(forwardPlane, 1)
    }

    function forwardPlane() {
// lets clear the canvas
      ctx.clearRect(0, 0, canvas.width, canvas.height);
//now we draw our target, wind, and wind power text every frame
      drawTarget()
      drawWind()
      drawWindPower()
      ctx.save();
//angle the plane to its angle the user had set
      ctx.translate(x, y);
      ctx.rotate(angle);
//here we draw our plane image
      ctx.drawImage(img,-(planeW/2),0,planeW,planeH)
//this logic checks if we are going left or right of the middle of the canvas (vertically).
//We then set the x,y based on the dx and dy values, incrementing it every time this animation loop happens.
      if (angle >= 0) {
          x -= (((canvas.width/2) - dx)/canvas.height) 
          y += (( dy-30)/canvas.height)
      }else{
          x += ((dx - (canvas.width/2))/canvas.height)
          y += (( dy-30)/canvas.height)
      } 
      ctx.restore();

//this is how we affect the plane's trajectory based on the wind
//the wind power have powers for both the X and Y axis
//we decrement the plane's destination coordinates every animation frame based on the wind powers
      dy -= windY
      dx -= windX
//we wait until the gravity variable (set in the start) runs out. 
//Once it does, we stop moving the plane and check for a collision with the target.
      if (gravity <= 0) {
        clearInterval(anglage)
        ctx.restore()
        slide.style.height = 0
        addScore(collision())
      }
//if gravity hasn't run out, we decrement it one each animation frame until it does.
      gravity -= 1


    }

    function getXY(sideC, angle){
      const sideA = sideC * Math.sin(angle)
      const sideB = Math.sqrt((sideC**2) - (sideA**2))
      return {sideA, sideB}
    }

    function getTrajectory() {
//setting the power of the plane to the slide height that the user set on when they clicked. 
      power = slide.clientHeight;
      let XY = getXY(power, angle)
      let moveY = XY.sideB
      let moveX = Math.abs(XY.sideA)
      return {y: moveY, x: moveX}
Enter fullscreen mode Exit fullscreen mode

There are some pieces missing here, but we have essentially got the plane angled and moving! After a round's functionality was working, I wrote some game logic. There were 10 rounds in a game, and each round would tally to your game's score. At the end of each game, we would send the score to the database.

Rails Backend

The rails backend would be very simple. I wanted there to be a user who has many scores, and scores which belong to users. To start the api, I used this command to easily set everything up quick: rails new filename --api --database postgresql.

Once I set up my database, models, and routes, I just had to render the json that I wanted to access from the frontend. My controllers looked something like this:

Scores:
 def index
    scores = Score.high_scores
    all = Score.all.count
    render json: {scores: scores, all: all}
  end
  def create
    Score.create(score_params)
    render json: {message: "success"}
  end
Enter fullscreen mode Exit fullscreen mode

The class method high_scores just takes in the top 25 scores for the game. When sending in new scores from the front end, I would include the current user id and then the score value.

My Users controller was equally simple.

  def create
    user = User.find_or_create_by(user_params)
    scores = user.scores.collect{|s|s.score}.reverse
    if user.save
      render json: {username: user.username, id: user.id, scores: scores}
    else
      render json: {error: {message: "Username cannot be blank"}}, status: 400
    end
  end

  def show
    user = User.find(params[:id])
    scores = user.scores.collect{|s|s.score}.reverse
    render json: {username: user.username, id: user.id, scores: scores}
  end
Enter fullscreen mode Exit fullscreen mode

I basically wanted to create or find a user before a game is played, and return their scores if they have played before.

And just like that, it was all set up. I was easily able to upload the api to Heroku given I was already using a postgresql db.

JS Fetch

Once I had the backend up on a server, I could make fetch requests from the frontend in order to create users, display and submit their scores. Here is an example of how I created users from the frontend:

//called when a user clicks a button in the frontend
function submitUser() {
//play sound effect
  woosh.play()
//remove the event listener to avoid multiple clicks
  newUserSubmit.removeEventListener('click', submitUser)
//loading card incase the server takes a second
  let loading = new loadCard(newUserContainer)

//make a config object for the fetch request. This will be a post request sending the value that the user submitted.
  let configObj = {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Accept": "application/json"
    },
    body: JSON.stringify({
      username: newUserInput.value
    })
  }
//send the fetch request
  fetch("https://planegame-api.herokuapp.com/users", configObj)
  .then(resp => resp.json())
//get the json response to JSON. Should be the new or existing user along with scores if they have played already
  .then(function(json){
    if (json.error === undefined){
//if there are no errors create a new User instance for the session and hide the loading card
      setUser(json)
      loading.hideCard()
    }else{
      alert(json.error.message)
      loading.hideCard()
    }
    newUserSubmit.addEventListener('click', submitUser)
    })
  .catch(function(error){ 
//if there are errors, lets do it again!
    alert("Please check your internet connection.")
    newUserSubmit.addEventListener('click', submitUser)
    loading.hideCard()
  })

}
Enter fullscreen mode Exit fullscreen mode

And there you have it. A simple post request that creates or finds a user in the api, and then returns that user's information for the JS frontend to work with throughout the session.

There was a lot to cover in this article, and I definitely did not get to it all. I hope some of the information in the article was helpful. As always, I welcome feedback on my code and will take any questions in the comments.

Top comments (0)