Side-Scrolling Game Design Experiment

2019

I designed a 2D side-scrolling game inspired by the classic Mario game and developed a proof of concept in Javascript, HTML canvas and WebGL.

Design

Characters

Environment

Putting Them Together

Developing the Game

Library

Developing the proof-of-concept game, I took advantage of the p5.js library which is a Javascript library for creating full set of drawings on canvas.

Creating the Game Environment

1. Rendering the Backdrop

Creating the backdrop is easy with just a few lines of code in the setup() function and a custom function to create gradient background.

setup() {
  // Render backdrop.
  background(250, 190, 160);
  let c1 = color(210, 130, 160); // top colour
  let c2 = color(250, 190, 160); // bottom colour
  setGradient(c1, c2);
}

/**
 * Set a gradient background based on top and bottom colour.
 * @param {object} top_colour - p5js color data object
 * @param {object} bottom_colour - p5js color data object
 */
function setGradient(top_colour, bottom_colour) {
  noFill();
  for (let y = 0; y < game.floorPos_y; y++) {
    let inter = map(y, 0, game.floorPos_y, 0, 1);
    let c = lerpColor(top_colour, bottom_colour, inter);
    stroke(c);
    line(0, y, width, y);
  }
}


2. Rendering the Sun

The sun is just a single entity with two circles that will be rendered on canvas.

draw() {
  // Render sun.
  renderSun()
}

/**
 * Renders the sun in the game environment.
 */
function renderSun() {
  push();
  noStroke();
  fill(255, 255, 255, 30);
  ellipse(width / 2, 150, 300);
  fill(255);
  ellipse(width / 2, 150, 200);
  pop();
}

3. Rendering Clouds

This is where it gets a little tricky because we need to render multiple random clouds in the game in order to create continuity in the game. We can create a function to return a random cloud object as the character moves from one screen to the next.

/**
 * Creates a cloud object and returns it.
 * @param {number} x_pos - x position of the cloud.
 * @param {number} y_pos - y position of the cloud.
 * @param {number} scale_val - scale of the cloud, where 1 is
 * 100% scale. (1.0 to 1.6 is recommended)
 */
function createCloud(x_pos, y_pos, scale_val) {
  let cloudObj = {
    x_pos: x_pos,
    y_pos: y_pos,
    scale: scale_val,

    // Render this cloud.
    render: function () {
      push()
      translate(this.x_pos + game.scrollPos * 0.1, this.y_pos)
      scale(this.scale)
      noStroke()
      fill(250, 210, 200)
      ellipse(34, 68, 68, 44)
      ellipse(106, 54, 118, 93)
      ellipse(176, 54, 118, 107)
      ellipse(246, 65, 78, 62)
      ellipse(294, 68, 61, 28)
      ellipse(332, 72, 44, 20)
      pop()
    },
  }

  return cloudObj
}

Then it can be called in this way inside the setup() function and then renders them in the draw() function.

setup() {
  let clouds = [];

  // Initialise clouds.
  for (let i = 0; i < cloud.quantity; i++) {
    let randomCloud = createCloud(
      i * cloud.randomGap,
      cloud.randomY,
      cloud.randomScale
    );
    // array of clouds to be rendered in draw() function
    clouds.push(randomCloud);
}

draw() {
  // Render clouds.
  for (let i = 0; i < clouds.length; i++) {
    clouds[i].render();
  }
}

4. Rendering Mountains

Similarly to clouds, we can do the same for mountains.

/**
 * Creates a mountain object and returns it.
 * @param {number} x_pos - x position of the mountain.
 * @param {number} scale_val - scale of the mountain, where 1 is
 * 100% scale. (1.0 to 1.6 is recommended)
 */
function createMountain(x_pos, scale_val) {
  let mountainObj = {
    x_pos: x_pos,
    y_pos: game.floorPos_y,
    scale: scale_val,

    // Render this mountain.
    render: function () {
      push()
      translate(this.x_pos + game.scrollPos * 0.3, this.y_pos)
      scale(this.scale, abs(this.scale))
      noStroke()
      fill(102, 86, 122)
      triangle(252 + 50, -133, 177 + 50, 0, 327 + 50, 0)
      fill(61, 54, 85)
      triangle(64, -85, 0, 0, 129, 0)
      triangle(177, -187, 57, 0, 296, 0)
      pop()
    },
  }

  return mountainObj
}

Then it can be called in this way inside the setup() function.

setup() {
  let mountains = [];

  // Initialise mountains
  for (let i = 0; i < mountain.quantity; i++) {
    let randomMountain = createMountain(
      i * mountain.randomGap,
      i * mountain.randomScale,
    );
    // array of mountains to be rendered in draw() function
    mountains.push(randomMountain);
}

draw() {
  // Render mountains.
  for (let i = 0; i < mountains.length; i++) {
    mountains[i].render();
  }
}

5. Rendering Trees

Simaliarly with clouds and mountains, we can do the same for trees.

/**
 * Creates a tree object and returns it.
 * @param {number} x_pos - x position of the tree.
 * @param {number} scale_val - scale of the tree, where 1 is
 * 100% scale. (1.0 to 1.5 is recommended)
 */
function createTree(x_pos, scale_val) {
  let treeObj = {
    x_pos: x_pos,
    y_pos: game.floorPos_y,
    scale: scale_val,

    // Render this tree.
    render: function () {
      push()
      translate(this.x_pos + game.scrollPos * 0.7, this.y_pos)
      scale(this.scale)
      noStroke()
      fill(46, 42, 74)
      // main trunk
      quad(-18, 0, -3.8, -449, 9.56, -449, 22, 0)
      // branches
      triangle(54.5, -270.5, 7.43, -237.5, 13.4, -228.5)
      triangle(-70, -261, -6.4, -197.3, -4, -216.2)
      triangle(67.07, -215, 1, -170, 9.5, -158)
      triangle(-63, -181, -11.9, -128.5, -4.43, -139.73)
      fill(56, 51, 89)
      // top leaves
      triangle(2.5, -341, -48.5, -301, 53.5, -301)
      triangle(2.5, -387, -54.5, -327, 59.5, -327)
      triangle(2.5, -424, -54.5, -364, 59.5, -364)
      triangle(2.5, -461, -54.5, -405, 59.5, -405)
      pop()
    },
  }

  return treeObj
}

Then it can be called in this way inside the setup() function.

setup() {
  let trees = [];

  // Initialise mountains
  for (let i = 0; i < tree.quantity; i++) {
    let randomTree = createTree(
      i * tree.gap + tree.randomX,
      i * tree.randomScale,
    );
    // array of trees to be rendered in draw() function
    trees.push(randomTree);
}

draw() {
  // Render trees.
  for (let i = 0; i < trees.length; i++) {
    trees[i].render();
  }
}

Creating the Game Engine

The game engine has several basic mechanics required:

  • Allow character movements with the keyboard or touch.
  • Check if character collide with the coin (earn points).
  • Check if character collide with enemy (minus live).

1. Character Rendering and Movements

The character rendering and its movements are created in a constructor function which contains the render and movement methods.

/**
 * Blueprint for a game character.
 * @constructor
 */
function Character() {
  // Character Render
  this.render = function () {
    //... Code for rendering different state of the character,
    // i.e. standing, walking, jumping, etc.
  }

  // Character Movement
  this.movement = function () {
    // Logic to make character move or background scroll.
    if (this.isLeft) {
      if (this.x_pos > width * 0.2) {
        this.x_pos -= 6
      } else {
        if (this.world_x_pos >= -600) {
          game.scrollPos += 6
        }
      }
    }

    if (this.isRight) {
      if (this.x_pos < width * 0.4) {
        this.x_pos += 6
      } else {
        game.scrollPos -= 6
      }
    }

    // Logic to make character rise and fall.
    if (this.y_pos < game.floorPos_y) {
      let isOnPlatform = false

      for (let i = 0; i < platforms.length; i++) {
        if (platforms[i].check(this)) {
          isOnPlatform = true
          this.isFalling = false
          break
        }
      }

      if (!isOnPlatform) {
        this.isFalling = true
        this.y_pos += 4
      }
    } else {
      this.isFalling = false
    }

    // Logic to make character plummet.
    if (this.isPlummeting) {
      this.y_pos += 30

      if (this.y_pos >= height) {
        this.respawn()
      }
    }

    // Updates character real world x position.
    this.world_x_pos = this.x_pos - game.scrollPos
  }

  //...
}

2. Collision Detection – Coins

In order to increase the scores, we need to detect when the character touches the coin. This logic can be implemented in the check method in the constructor function of the coin object and passing in the character object.

/**
 * Blueprint for coin.
 * @param {number} x_pos - x position of the coin
 * @param {number} y_pos - y position of the coin
 * @constructor
 */
function Coin(x_pos, y_pos) {
  /**
   * Check if character has collided with the coin,
   * then returns true. Otherwise, returns false.
   * @param {object} character - character object
   */
  this.check = function (character) {
    let d = dist(character.world_x_pos, character.y_pos, this.x_pos, this.y_pos)
    return d <= this.size + 50
  }
}

Somewhere in the code, we can add this logic to the main game engine:

if (coin.check(character)) {
  character.score(1)
}

3. Collision Detection – Enemy

Similarly to the coin, we can do the same with enemy and reduce the character's lives by 1.

/**
 * Blueprint for enemy.
 * @param {number} x_pos - x position of enemy
 * @param {number} y_pos - y position of enemy
 * @param {number} walk_range - walking range of enemy by pixel
 * @constructor
 */
function Enemy(x_pos, y_pos, walk_range) {
  /**
   * Check if the character made contact with the enemy,
   * then returns true. Otherwise, returns false.
   * @param {object} character - character object
   */
  this.check = function (character) {
    return (
      dist(
        character.world_x_pos,
        character.y_pos,
        this.offset_x_pos,
        this.y_pos - 25
      ) < 45
    )
  }
}

Somewhere in the code, we can add this logic to the main game engine:

if (enemy.check(character)) {
  character.die(1)
}