Platformer

Simple side-scrolling platformer game

jkwok91@jkwok91
Edit this page!

What you'll build:

Square particles moving in a game

We recommend going through this workshop in Google Chrome.

In this workshop we'll continue our foray into game development in p5.js by creating a simple platformer. It should take around 60 minutes.

Live Demo

Final Code


Part I: Set-up

Go ahead and spin up a new HTML repl and proceed once it's loaded

Priming the HTML File

Clear the contents of index.html and put the following in its place

<!DOCTYPE html>
<html>
  <head>
    <title>Platformer Game</title>
  </head>
  <body></body>
</html>

Load in p5.js in script tag in body. Beneath that, load in p5.play.js in another script tag. Beneath both of those, load in our game file (script.js) in a third script tag.

<body>
  <script
    type="text/javascript"
    src="https://cdn.jsdelivr.net/npm/p5@1.0.0/lib/p5.js"
  ></script>
  <script
    type="text/javascript"
    src="https://cdn.jsdelivr.net/gh/molleindustria/p5.play@42cd19c3/lib/p5.play.js"
  ></script>
  <script
    type="text/javascript"
    src="https://repl.it/@prophetorpheus/platformer"
  ></script>
  <script type="text/javascript" src="script.js"></script>
</body>

Then, save and open live preview.

Priming the JavaScript File

Click on script.js and add in the functions setup() and draw():

function setup() {}

function draw() {}

Save and refresh live preview.

Part II: Building the World

Setting the Stage

Call createCanvas() in setup():

createCanvas(400, 300)

Set the background to a light blue color (150, 200, 250), in setup(), beneath createCanvas():

background(150, 200, 250)

We're going to be making a scrolling floor, so we need a series of sprites to illustrate the ground.

p5.play has a nice way to keep these organized, with Group. We can declare the variable at the top and define a new Group in setup(), like so:

var groundSprites

function setup() {
  createCanvas(400, 300)
  background(150, 200, 250)
  groundSprites = new Group()
}

Let's set the width of a ground sprite, so we know how many to lay across the canvas. I've decided to make each ground sprite have a width of 50 pixels. We can add this line right beneath our declaration of groundSprites:

var groundSprites
var GROUND_SPRITE_WIDTH = 50
var GROUND_SPRITE_HEIGHT = 50

I've added in the ground sprite height as well.

We can now determine and set the number of ground sprites in setup():

var groundSprites
var GROUND_SPRITE_WIDTH = 50
var GROUND_SPRITE_HEIGHT = 50
var numGroundSprites

function setup() {
  createCanvas(400, 300)
  background(150, 200, 250)
  groundSprites = new Group()

  numGroundSprites = width / GROUND_SPRITE_WIDTH
}

And now we'll populate the empty Group we've created. The number of ground sprites we should create is numGroundSprites. Using a for-loop, we can keep track of how many times we'll be performing the actions of creating a new sprite and adding it to the Group.

function setup() {
  createCanvas(400, 300)
  background(150, 200, 250)
  groundSprites = new Group()

  numGroundSprites = width / GROUND_SPRITE_WIDTH
  for (var n = 0; n < numGroundSprites; n++) {
    var groundSprite = createSprite(
      n * 50,
      height - 25,
      GROUND_SPRITE_WIDTH,
      GROUND_SPRITE_HEIGHT
    )
    groundSprites.add(groundSprite)
  }
}

Don't forget to draw the sprites by calling drawSprites() in draw():

function draw() {
  drawSprites()
}

Not only are we leveraging our for-loop to perform a series of actions multiple times, we're also using it to set the x-position of each ground sprite, and having them line up along the bottom of the canvas.

Save and refresh live preview to see your beautiful ground!

You might notice there's a blank spot at the right edge of the ground. We could shift over our starting ground sprite, but since we want our ground to be scrolling, we'll just add in an extra ground sprite. We can generate an extra one by adding 1 to numGroundSprites. Now the for-loop will run one extra time, generating an extra ground sprite and adding it to the Group.

numGroundSprites = width / GROUND_SPRITE_WIDTH + 1

Adding Gravity

Since our player will be jumping, we should introduce gravity to bring it back to the ground. We can define it at the top of the file, above groundSprites:

var GRAVITY = 0.3

The value is up to you. More gravity will mean that your player jumps get less height. Less gravity will mean higher jumps. We'll use this later.

That's it for our world. Next up, the player.

Part III: Creating the Player

Creating the Player Sprite

We'll be creating the player with createSprite().

Declare the variable that will store the player sprite underneath our other global variables, at the top of the file.

var numGroundSprites

var player

Let's make our sprite a 50x50 square for now. We'll give it an initial position of (100, height-75), putting it 75 pixels from the left edge of the screen, and 50 pixels above the bottom (remember that this accounts for half of the sprite's width and height), which sets it right on top of the ground. Type this line at the end of setup():

player = createSprite(100, height - 75, 50, 50)

Making our Player Move

Let's have the player move continuously by adding this line to draw(), before drawSprites();:

player.position.x = player.position.x + 5

Don't forget to redraw the background so the illusion is complete. Place the following line at the top of the draw() function:

background(150, 200, 250)

Save and refresh live preview. Whoops! Your player disappears right off the screen!

Using Camera to Track Player Movement

We can use the camera to follow the player's movement. p5.play offers the functionality of a camera, which is automatically created. We just reference it with camera.

Let's try setting the position of the camera to the position of the player. This will make the camera always follow the player. Add this line directly beneath the line that modifies player movement.

camera.position.x = player.position.x

Save and refresh to see the player in action!

Adjusting Camera

Having the player at the center of the screen limits our visual of the obstacles.

We can adjust the camera x-position by setting it slightly ahead of the player, so that we get more screen real estate. Modify the previous line by adding to the camera x-position:

camera.position.x = player.position.x + width / 4

Adjusting the Ground

What we'd like to do is modify the position of the first ground sprite so that it immediately follows the last ground sprite.

Blocks go from the left of the screen and then back to the right

We can select the first ground sprite and store it in a variable by typing the following line in the draw() function. I put this logic beneath the line that sets camera x-position.

var firstGroundSprite = groundSprites[0]

[0] refers to the sprite in the Group at index 0.

Next, we'll check if it has moved off screen, by comparing its x-position with the left edge of the camera.

The x-position of the camera is camera.position.x. The x-position of the left edge of the camera is camera.position.x - width/2. Remember, the camera is at the middle of the screen.

We'll set up a conditional to check if the x-position of the ground sprite is less than that of the left edge of the camera.

We will put this conditional directly beneath the definition of the firstGroundSprite variable;

var firstGroundSprite = groundSprites[0]
if (firstGroundSprite.position.x <= camera.position.x - width / 2) {
}

If it is indeed off-screen, then we must remove it from its current index in Group, alter its x-position, and re-append it to the Group at the last index. It's important to note that we're removing it from the groundSprites Group, but not from our program.

It's like we're recycling the first ground sprite to be the last ground sprite by changing its position! We have to remove and re-add it so the "first" ground sprite in the Group is also the first ground sprite we see on screen.

var firstGroundSprite = groundSprites[0]
if (firstGroundSprite.position.x <= camera.position.x - width / 2) {
  groundSprites.remove(firstGroundSprite)
  // firstGroundSprite.position.x = ??
  groundSprites.add(firstGroundSprite)
}

There's just one other thing: What do we set the x-position to? With a bit of arithmetic, we can figure out that the distance between its current x-position and position we'd like it to be is numGroundSprites*firstGroundSprite.width. So we should add that amount to its current position.

var firstGroundSprite = groundSprites[0]
if (firstGroundSprite.position.x <= camera.position.x - width / 2) {
  groundSprites.remove(firstGroundSprite)
  firstGroundSprite.position.x =
    firstGroundSprite.position.x + numGroundSprites * firstGroundSprite.width
  groundSprites.add(firstGroundSprite)
}

Save and refresh live preview. Hm, does something look funny to you? Specifically, how the first ground sprite is just blipping off the left edge instead of smoothly sliding off?

Text and diagrams describing the sprites position

This is an issue of not properly offsetting the sprite. Remember that the position of a sprite is at its center, so we must edit our conditional to include this offset. It should now read:

if (
  firstGroundSprite.position.x <=
  camera.position.x - (width / 2 + firstGroundSprite.width / 2)
) {
}

Adding Player Controls

We'll define a certain strength our player can jump at the top of the file, underneath gravity:

var JUMP = -5

If you're wondering why this value is negative, remember that the y-axis on p5's coordinate system is flipped. Thus, higher numbers are further down, and smaller numbers are further up.

Let's have the up arrow control the jumping. Place this part in draw(), right above the line that modifies the player's x-position:

if (keyDown(UP_ARROW)) {
}

p5.play has this handy function keyDown() that will check if the up arrow key was pressed.

To make the player jump, we'll modify its velocity in the y direction. p5.play has us covered, with this super straightforward velocity property, accessible by player.velocity. All we have to do is set it to JUMP. We'll do this within our conditional:

if (keyDown(UP_ARROW)) {
  player.velocity.y = JUMP
}

Save and refresh live preview, and give it a try by pressing your up arrow key!

Oops, looks like we never established gravity in our world! Place the following line within draw(), beneath background(150, 200, 250):

player.velocity.y = player.velocity.y + GRAVITY

That should do it. The player will definitely come back down now. Save and refresh to check it out.

The player's going down, alright. In fact, it's going through the ground.

We'll have to place a restriction on this. That is, we want to check if the player intersects with the ground. If so, we'd like to set the player's y-velocity back to 0, and ensure that the player rests on the ground.

Add a conditional under the previous line, but before the conditional for jumping, like this:

player.velocity.y = player.velocity.y + GRAVITY

if (groundSprites.overlap(player)) {
  player.velocity.y = 0
  player.position.y = height - 50 - player.height / 2
}

if (keyDown(UP_ARROW)) {
  player.velocity.y = JUMP
}

Here, we're checking if any sprites within the Group overlap with the player. If that does happen, then we stop moving the player downwards, and stabilize the player at ground-level.

Part IV: Adding Obstacles

Generating Obstacles

This isn't much of a game right now. All we can do is practice jumping.

Let's add some obstacles to jump over.

For this, we'll need to make another Group. Let's create one at the top, under our player declaration.

var obstacleSprites

Now we'll initialize it in setup(), in the same way we did groundSprites. Do this at the end of setup(), right after the creation of the player sprite:

obstacleSprites = new Group()

Now that we've got somewhere to store the obstacles, we can start generating some within draw().

We can set the x-coordinate as camera.position.x + width to ensure they're generated off-screen to the right. We'll have the obstacles sit on the ground, by giving each a y-coordinate of (height-50)-15. Finally, we'll set the height and width of each obstacle at 30 for now.

Let's type the following right above drawSprites(), inside draw():

var obstacle = createSprite(camera.position.x + width, height - 50 - 15, 30, 30)

Gah! Too many obstacles! Remember that the draw() function is called repeatedly, 60 times per second!

We can have randomized generation if we wrap the above line in a conditional. We'll use the random() function to generate a random number between 0 and 1, and create a new obstacle only if it's above 0.95. This means there's a 5% chance a new obstacle will be generated during every call of draw().

Let's place the previous line into a conditional:

if (random() > 0.95) {
  var obstacle = createSprite(
    camera.position.x + width,
    height - 50 - 15,
    30,
    30
  )
}

We'll also add the obstacle to our Group:

if (random() > 0.95) {
  var obstacle = createSprite(
    camera.position.x + width,
    height - 50 - 15,
    30,
    30
  )
  obstacleSprites.add(obstacle)
}

And we'll set up some logic to remove from the program entirely when it goes off-screen, similar to how we created a check for the ground sprites.

Let's add this underneath the conditional for generation, like so:

if (random() > 0.95) {
  var obstacle = createSprite(
    camera.position.x + width,
    height - 50 - 15,
    30,
    30
  )
  obstacleSprites.add(obstacle)
}

var firstObstacle = obstacleSprites[0]
if (
  firstObstacle.position.x <=
  camera.position.x - (width / 2 + firstObstacle.width / 2)
) {
  removeSprite(firstObstacle)
}

First, we get the first sprite in obstacleSprites, and store it in a variable called firstObstacle. Then we check to see if it's disappeared off the screen (with offset of half the obstacle's width).

If it has, then we remove it from our program.

If you save and refresh live preview, you'll realize something seems to be wrong! Let's open up our external live preview and inspect. The error says Uncaught TypeError: Cannot read property 'position' of undefined

This is because at the very beginning of the game, no obstacles have yet been generated! Our obstacleSprites is empty, and there are no obstacles to check the position of! To solve this, we must add the condition obstacleSprites.length > 0 to our conditional:

Here we're saying "if obstacleSprites has more than 0 things, AND the right edge (offsetting!) of the first obstacle is less than the left-screen bound, then get rid of it."

Your modified conditional should now look like this:

if (
  obstacleSprites.length > 0 &&
  firstObstacle.position.x <=
    camera.position.x - (width / 2 + firstObstacle.width / 2)
) {
  removeSprite(firstObstacle)
}

Detecting Collisions

Great, we've got a good assortment of randomly generated obstacles. Let's add collisions!

We've used .overlap() with our groundSprites Group above, by providing just one argument. We also can use it on our obstacleSprites Group by providing two arguments, the thing to collide with, and a function to do something when there is a collision.

Add this to the draw() function, right before the call for drawSprites():

obstacleSprites.overlap(player, endGame)

In this line, we are handling a collision between any of the sprites in obstacleSprites and our player. When a collision happens, we'll run the endGame function (which we'll define next).

Handling Collisions

At the bottom of script.js, below our draw() function, we can add our endGame function:

function endGame() {}

Let's use something called console.log() to create output in our console. If we add this to endGame(), we'll see "Game Over!" in our console in the Inspector.

function endGame() {
  console.log('Game Over!')
}

Save and view your game in the external live preview. Check your console in the Inspector.

Great. Now that we've made sure that collisions get handled properly by this function, we can flesh out the game-over behavior a bit more.

Part V: Ending Game

Similar to what we did in Dodge, we'll be splitting the draw() function into two modes: game over and game not over. Let's add a variable to keep track of this at the top of the file, and initialize it as false at the top of setup().

var isGameOver

function setup() {
  isGameOver = false
}

We'll add a line to set this flag to true within our endGame(), replacing the console.log():

function endGame() {
  isGameOver = true
}

And we'll create the two modes now:

  1. Add a conditional at the top for the game over mode
function draw() {
  if (isGameOver) {
  }

  background(150, 200, 250)

  // ...
}
  1. Wrap the game logic we've written in draw() so far in else { }.
function draw() {
  if (isGameOver) {
  } else {
    background(150, 200, 250)

    // ...
  }
}

Allowing Restart

We mustn't forget to flip the flag back to false, in order to restart the game. I'll tie my restart logic to a mouse click, by creating p5's mouseClicked() function beneath endGame():

function mouseClicked() {
  if (isGameOver) {
    isGameOver = false
  }
}

Resetting

To bring the game back to its original state, we have to perform the following actions before setting isGameOver back to false. Let's add all of this above isGameOver = false in our mouseClicked() function. We'll want to reset everything before we restart:

  • reset the x-position of each ground sprite within groundSprites:

    for (var n = 0; n < numGroundSprites; n++) {
      var groundSprite = groundSprites[n]
      groundSprite.position.x = n * 50
    }
    
  • reset the player position back to its initial x and y coordinates

    player.position.x = 100
    player.position.y = height - 75
    
  • clear the obstacleSprites Group by removing the existing obstacle sprites

    obstacleSprites.removeSprites()
    

You should end up with a mouseClicked() function that looks like this:

function mouseClicked() {
  if (isGameOver) {
    for (var n = 0; n < numGroundSprites; n++) {
      var groundSprite = groundSprites[n]
      groundSprite.position.x = n * 50
    }

    player.position.x = 100
    player.position.y = height - 75

    obstacleSprites.removeSprites()

    isGameOver = false
  }
}

Fixing up our Game Over Screen

Let's create a Game Over screen, by telling draw() what should be drawn when isGameOver is true:

function draw() {
  if (isGameOver) {
    background(0)
    fill(255)
    textAlign(CENTER)
    text(
      'Game Over! Click anywhere to restart',
      camera.position.x,
      camera.position.y
    )
  } else {
    // ...
  }
}

Part VI: Keeping Score

Keeping Track of Score

Let's add the scorekeeper now. First we'll need a variable to keep track of the score. Add it to the top of the file, right under var isGameOver;:

var score

And initialize it to 0 in setup(), beneath isGameOver:

function setup() {
  isGameOver = false
  score = 0

  createCanvas(400, 300)
  background(150, 200, 250)

  // ...
}

Now we'll increment the score in draw(). We'll just increment by 1 every time draw() is called. We can add this line beneath drawSprites().

score = score + 1

Lastly, we'll need to reset our score to 0 in mouseClicked(). Place this line right before resetting isGameOver:

score = 0

Displaying Score

Now we'll need to display the score during the game. We can use text() and position it relative to the camera. Type the following beneath the line that increments score, near the end of draw(), like this:

score = score + 1
textAlign(CENTER)
text(score, camera.position.x, 10)

Save and refresh. Your score should be proudly displayed now!

Display Score in Game Over

Now, let's display the score after the game ends. We can add the value of score to text by concatenating the score onto our string with a +:

'Your score was: ' + score

Let's add this text to our Game Over screen by adding the following line above the "Game Over" text in draw(), like this. We'll offset it slightly in the y-direction to avoid overlap.

text('Your score was: ' + score, camera.position.x, camera.position.y - 20)
text(
  'Game Over! Click anywhere to restart',
  camera.position.x,
  camera.position.y
)

Part VII: Upgrading

Changing Y-Position of Obstacles

It's not too exciting right now since all of the obstacles are at the bottom. We can give them different y-coordinate values using our familiar friend, the random() function!

Find the line where we create obstacles and modify the argument we are passing in for the y-coordinate, like so:

var obstacle = createSprite(
  camera.position.x + width,
  random(0, height - 50 - 15),
  30,
  30
)

Part VIII: Customizing

It's time to add in our sprite images! This exercise is left to the user.

Part IX: Publishing and Sharing

Make sure you're logged into your Repl.it account and, if you want to, go ahead and change the name of your repl by clicking on the pencil next to it.

Edit button for changing project name

Your game is now live and playable on REPL_NAME--USERNAME.repl.co!!

Be sure to post your creation in the #ship channel on Slack!

Part X: Hacking

  • Create a Start Screen with a title card and instructions.
  • Add jewels to collect for bonus points. Hint, it's just like obstacles, except instead of dying when you hit one, you gain a score boost.
  • Change the terrain
  • Modify the camera motion to be independent of the player. Instead of losing when you hit an obstacle, you lose when you get stuck and end up off-screen on the left.

Examples:

We'd love to see what you've made!

Share a link to your project (through Replit, GitHub etc.)