Building a Platformer Game - Fixed

Has anyone else noticed just how bad the platformer game from the JavaScript and Data Structures Certification is? Specifically, the movement and collision engine. You can fly by holding spacebar/up, if you keep going to the right you eventually leave the screen, etc. Overall, it looks like the code is there to fix most of these, but none of it is working as intended. Additionally, there are several concepts covered in the previous projects that would be beneficial to this project but seem to be ignored (deconstruction being one of them). The biggest culprit seems to be that they are adjusting the players velocity in 3 or 4 sections of the code instead of centralizing it.

So I, in the interest of getting more practice, fixed it. I tried to keep the overall structure similar to what they had, and in cases where it didn’t impede the legibility or functionality of the game, I tried to keep the same methods. I also limited myself to the concepts that they had already covered to this point. In the end, the keypress system, movement system, collision system, etc. all got a massive revamp. And it looks like like my code is any longer than the original. If anything, it seems to be more succinct.

So, here’s my JS code (didn’t change the HTML/CSS). Feel free to provide thoughts on any improvements I missed or changes you would add.

const startBtn = document.getElementById("start-btn");
const canvas = document.getElementById("canvas");
const startScreen = document.querySelector(".start-screen");
const checkpointScreen = document.querySelector(".checkpoint-screen");
const checkpointMessage = document.querySelector(".checkpoint-screen > p");

const ctx = canvas.getContext("2d");
canvas.width = innerWidth;
canvas.height = innerHeight;

const margin = {
  left: canvas.width / 4,
  right: canvas.width / 4,
  top: 10,
  bottom: 10,
};

const gravity = 0.5;

/* Define classes and initialize objects*/

class Platform {
  constructor(x, y) {
    this.width = 200;
    this.height = 40;
    this.position = {
      x,
      y,
    };
  }

  draw() {
    ctx.fillStyle = "#acd157";
    ctx.fillRect(this.position.x, this.position.y, this.width, this.height);
  }
}
const platformPositions = [
  { x: 400, y: 450 },
  { x: 600, y: 400 },
  { x: 800, y: 350 },
  { x: 950, y: 150 },
  { x: 2400, y: 450 },
  { x: 2800, y: 400 },
  { x: 3050, y: 350 },
  { x: 3800, y: 450 },
  { x: 4100, y: 400 },
  { x: 4300, y: 200 },
  { x: 4600, y: 150 },
];
const platforms = platformPositions.map(({ x, y }) => new Platform(x, y));

class CheckPoint {
  constructor(x, y) {
    this.width = 40;
    this.height = 70;
    this.position = {
      x,
      y,
    };
  }

  draw() {
    ctx.fillStyle = "#f1be32";
    ctx.fillRect(this.position.x, this.position.y, this.width, this.height);
  }
}
const checkpointPositions = [
  { x: 1070, y: 80 },
  { x: 2800, y: 330 },
  { x: 4700, y: 80 },
];
const checkpoints = checkpointPositions.map(({ x, y }) => new CheckPoint(x, y));

//Checkpoints are removed when they are collided with. Game is over when there are no more checkpoints
const isGameActive = () => checkpoints.length > 0;

class Player {
  constructor() {
    this.width = 40;
    this.height = 40;

    this.position = {
      x: 0,
      y: canvas.height - this.height - margin.bottom,
    };

    this.velocity = {
      x: 0,
      y: 0,
    };

    this.acceleration = 1; //additive
    this.deceleration = 0.9; //multiplicative. Speed cap will be acceleration/(1-deceleration)
    this.jumpForce = 20; //additive, may need to be adjusted depending on screen size
    this.isGrounded = true;
  }

  draw() {
    ctx.fillStyle = "#99c9ff";
    ctx.fillRect(this.position.x, this.position.y, this.width, this.height);
  }

  move() {
    this.verticalMovement();
    this.horizontalMovement();
  }

  verticalMovement() {
    //Update vertical velocity
    if(!this.isGrounded) {
      this.velocity.y += gravity;
    } else if (keys.up && isGameActive()){
      this.velocity.y -= this.jumpForce;
    }

    //Update vertical position
    this.position.y += this.velocity.y;
    
    //Assume the player is no longer grounded
    this.isGrounded = false;

    //Check for vertical platform collisions
    platforms.forEach((platform) => {
      const topsideCollisionRules = [
        this.position.x + this.width > platform.position.x,
        this.position.x < platform.position.x + platform.width,
        this.position.y < platform.position.y,
        this.position.y + this.height > platform.position.y,
      ];

      const undersideCollisionRules = [
        this.position.x + this.width > platform.position.x,
        this.position.x < platform.position.x + platform.width,
        this.position.y > platform.position.y,
        this.position.y < platform.position.y + platform.height,
      ];

      if (topsideCollisionRules.every((rule) => rule)) {
        this.position.y = platform.position.y - this.height;
        this.velocity.y = 0;
        this.isGrounded = true;
      } else if (undersideCollisionRules.every((rule) => rule)) {
        this.position.y = platform.position.y + platform.height;
        this.velocity.y = 0;
      }
    });

    //Check for top and bottom collisions
    if (this.position.y < margin.top) {
      this.position.y = margin.top;
      this.velocity.y = 0;
    } else if (this.position.y + this.height >= canvas.height - margin.bottom) {
      this.position.y = canvas.height - this.height - margin.bottom;
      this.velocity.y = 0;
      this.isGrounded = true;
    }
  }

  horizontalMovement() {
    //Update horizonal velocity
    if (keys.right && isGameActive()) {
      this.velocity.x += this.acceleration;
    }
    if (keys.left && isGameActive()) {
      this.velocity.x -= this.acceleration;
    }
    //Both brings the player to a stop if not accelerating, and forces a speed cap
    this.velocity.x *= this.deceleration;

    //Update position
    this.position.x += this.velocity.x;

    //Check for horizontal platform collisions
    platforms.forEach((platform) => {
      const frontalCollisionRules = [
        this.position.y + this.height > platform.position.y,
        this.position.y < platform.position.y + platform.height,
        this.position.x < platform.position.x,
        this.position.x + this.width > platform.position.x
      ];

      const rearCollisionRules = [
        this.position.y + this.height > platform.position.y,
        this.position.y < platform.position.y + platform.height,
        this.position.x > platform.position.x,
        this.position.x < platform.position.x + platform.width
      ];
      
      if (frontalCollisionRules.every(rule => rule)) {
        this.position.x = platform.position.x - this.width;
        this.velocity.x = 0;
      } else if (rearCollisionRules.every(rule => rule)) {
        this.position.x = platform.position.x + platform.width;
        this.velocity.x = 0;
      }
    });

    //Check left and right border collisions and set necessary adjustment for shifting
    const horizonalAdjustment =
      this.position.x < margin.left
        ? margin.left - this.position.x
        : this.position.x + this.width > canvas.width - margin.right
          ? canvas.width - margin.right - this.position.x - this.width
          : 0;

    //Shift if necessary
    if(horizonalAdjustment !== 0) {
      checkpoints.forEach((checkpoint) => {
        checkpoint.position.x += horizonalAdjustment;
      });
      platforms.forEach((platform) => {
        platform.position.x += horizonalAdjustment;
      });
      this.position.x += horizonalAdjustment;
    }
  }

  evaluateCheckpoints() {
    checkpoints.forEach((checkpoint, index) => {
      const collisionRules = [
        this.position.x + this.width > checkpoint.position.x,
        this.position.x < checkpoint.position.x + checkpoint.width,
        this.position.y + this.height > checkpoint.position.y,
        this.position.y < checkpoint.position.y + checkpoint.height
      ];
      
      if (collisionRules.every(rule => rule)) {
        //Remove checkpoint
        checkpoints.splice(index, 1);

        //Display checkpointMessage
        checkpointScreen.style.display = "block";
        if (isGameActive()) {
          checkpointMessage.innerText = "You reached a checkpoint!";
          setTimeout(() => (checkpointScreen.style.display = "none"), 2000);
        } else {
          checkpointMessage.textContent = "You reached the final checkpoint!";
        }
      }
    });
  }
}
const player = new Player();

/* Define animation sequence */

const animate = () => {
  requestAnimationFrame(animate);
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  player.move();
  player.evaluateCheckpoints();
  player.draw();

  platforms.forEach((platform) => {
    platform.draw();
  });

  checkpoints.forEach((checkpoint) => {
    checkpoint.draw();
  });
};

/* Event listeners */

//Start game
startBtn.addEventListener("click", () => {
  canvas.style.display = "block";
  startScreen.style.display = "none";
  animate();
});

//Key press and release
const keys = {
  right: false,
  left: false,
  up: false,
};
const updateKey = (key, isPressed) => {
  switch (key) {
    case "ArrowLeft":
      keys.left = isPressed;
      break;
    case "ArrowUp":
    case " ":
    case "Spacebar":
      keys.up = isPressed;
      break;
    case "ArrowRight":
      keys.right = isPressed;
  }
};

window.addEventListener("keydown", ({ key }) => {
  updateKey(key, true);
});
window.addEventListener("keyup", ({ key }) => {
  updateKey(key, false);
});
1 Like

Can you talk a bit about what code changes you made and the effects they had? The work sounds interesting but could be a more compelling point for a start of a portfolio piece if you can communicate how your changes work!

1 Like

Edge collision and screen margin system:
I noticed that they had worked into the code the ability for the platforms/checkpoints to shift to the left/right if the player was moving off the screen, allowing the player to “keep moving” without leaving the screen. However, the base code doesn’t implement this right, resulting in the player being able to leave the screen if moving to the left. An additional issue is that it isn’t well-defined which portion of the screen the player is supposed to stay inside of. So I used a margin system, allowing you to set the playable space. I intentionally set it as the middle half of the screen, giving the player a bit more visibility as he moves. The margin object is thus used in all of the edge collision code.

Bad physics
I noticed that the side-to-side movement was irregular and that holding the spacebar would allow you to fly. Part of the culprit here seems to be that the main code changes the player’s velocity in multiple spots.

  • Irregular movement:
    I standardized movement to a move method within the Player class. This allowed me to update velocity based on keypress statuses then update positions appropriately, following which I could check for collisions and adjust the player’s position accordingly. This is where I reimplemented the ability for the screen to shift to the left/right as needed. I also refactored the movePlayer function into updateKey to simply update the keypress statuses instead of changing velocity directly.
  • Flying:
    I implemented an isGrounded boolean for the Player class. This allowed me to restrict upward “jumps” to when the player is in contact with either the bottom of the screen or the top of a platform.

Poorly implemented Collisions:
Much of the above is only possible due to updating the collision structure. The source code only really supported top/bottom collisions with a platform. Frontal or rear collisions would pop the player to the bottom of the platform. Additionally, it relied a little too strongly on checking not only the player’s position but also his velocity, which made the code much harder to follow.
The biggest fix for this was to completely separate the player’s movement into verticalMovement and horizontalMovement. This allows the code to move the player along one axis, then correct collisions on that axis, before moving the player along the other axis and correcting that axis as well. For this to work properly, the collision checks were moved out of the animate function and into the movement methods of the Player class.

Quality of Life / Legibility improvements:

  • Replaced the isCheckpointCollisionDetectionActive variable with an isGameActive function. Additionally, instead of moving “used” checkpoints out of visible range, I simply removed them from the checkpoints array, meaning that they are no longer being rendered and allowing me to compare against the array’s length to know if the game is over.
  • I used a deconstructor when parsing the platformPositions and checkpointPositions arrays to initialize the platforms and checkpoints objects.
  • Removed the showCheckpointScreen function as it was only being used in a single section of the code and was resulting in unnecessary isGameActive calls.
  • Removed the need to use .pressed when checking the status of a key.
  • Moved the startGame function into the event listener for the start button, as it is only called once.
1 Like