Welcome to the fourth episode of Platformer From Scratch! In this chapter, we’ll put the finishing touches on the physics engine and add lava, coins, and basic enemies, as well as implement player death. Finally, we’ll add a minimum-extent clause to the _create
command which allows us to kill the player if it falls off a platform. Let’s get started!
Right now, our physics engine is very fixed and can do nothing but hit solid objects. We’re going to change that now by exploiting class inheritance.
Create 4 empty methods on the PhysicsObject
class, named hitBottom
(to be called when the player hits something on it’s bottom edge), hitTop
(same idea), hitLeft
, and hitRight
, like so:
hitBottom(){
}
hitTop(){
}
hitLeft(){
}
hitRight(){
}
Now, add these to the collision area. When this.touchingTop
is set to true
, this.hitTop
must be called, and the same pattern for the other three sides; this allows for more advanced physics later on. Your loop
function in PhysicsObject
should now look like this:
loop(){
if (!this.isStatic){
this.touchingTop = false;
this.touchingBottom = false;
this.touchingLeft = false;
this.touchingRight = false;
this.xv *= this.friction;
this.yv += this.gravity;
this.move(this.xv, 0);
var collX = this.doCollision(this.game.checkCollision(this));
if (collX[0]){
while (this.doCollision(this.game.checkCollision(this, collX[1]))[0]){
this.move(-Math.abs(this.xv)/this.xv, 0);
}
if (this.xv > 0){ // Positive velocity = moving right
this.touchingRight = true;
this.hitRight();
}
else if (this.xv < 0){ // Negative velocity = moving left
this.touchingLeft = true;
this.hitLeft();
}
this.xv = 0;
}
this.move(0, this.yv);
var collY = this.doCollision(this.game.checkCollision(this));
if (collY[0]){
while (this.doCollision(this.game.checkCollision(this, collY[1]))[0]){
this.move(0, -Math.abs(this.yv)/this.yv);
}
if (this.yv > 0){ // Positive velocity = moving down
this.touchingBottom = true;
this.hitBottom();
}
else if (this.yv < 0){ // Negative velocity = moving up
this.touchingTop = true;
this.hitTop();
}
this.yv = 0;
}
}
}
This doesn’t seem to do a whole lot, but here’s the trick: in anything inheriting from PhysicsObject
, if you define a function hitTop
or hitBottom
or hitLeft
or hitRight
, that function will be called every time the player hits the respective side - this makes physics handling very easy. However, right now, other types of brick won’t work in physics - they’ll either register as solid, or Player will pass right through them. If we want jump-through platforms and the like, we’ll need to make the engine more advanced. Start by adding another forEach
to the doCollision
function, this one one this.specialCollisions
, like so:
this.specialCollisions.forEach((item, index) => {
});
That wasn’t so bad. We’ve done a lot of forEach
es, so I hope you understand the syntax by now; if you don’t, you can always visit the one true love of all developers: MDN. Now, inside the new forEach
, do the same check as the previous one:
if (coll[item][0] > 0){
}
Pretty simple. Check if the collisionset’s entry for the current referenced collision type exists. However, we don’t want to set returner[0]
to true
or push
anything to returner[1]
, we want to call a function for special collisions:
this.specialCollision(item);
Note that this.specialCollision
doesn’t exist yet; you can define it as an empty function along with the hit<side>
functions. It will be overridden in PhysicsObject
children that need it. We may add logic for conditional collisions with special types later; for now, they cannot trip real collisions.
Your doCollision
function should now look like this:
doCollision(coll){
var returner = [false, []];
this.collisions.forEach((item, i) => {
if (coll[item][0] > 0){
returner[0] = true;
returner[1].push(...coll[item][1]); // This is unpacking magic.
}
});
this.specialCollisions.forEach((item, index) => {
if (coll[item][0] > 0){
this.specialCollision(item);
}
});
return returner;
}
That wasn’t so bad, right? And now, we can set up special collisions for a new type: lava! Start by adding a new call to your brick creation area: game.create(<x>, <y>, 1, 1, "lava", "killu")
. Run it and… nothing! We haven’t defined a style rule for lava yet, so it’s time to revisit the CSS. Go into main.css
and add:
.lava{
background-color: red;
}
Does this look familiar? Good, it’s almost the same code as for normal bricks, just with a different class name (lava
instead of normal
) and with a different color. When you run it, you should see a red lava brick!
If you have issues with block creation, you can use this updated test level:
game.create(-2, 4, 7, 1, "normal", "solid");
game.create(-2, 3, 1, 1, "normal", "solid");
game.create(4, 3, 1, 1, "normal", "solid");
game.create(3, 3, 1, 1, "lava", "killu");
Try running into the lava and the game will freeze; if you open the development console, you’ll see a slurry of errors. This is because we haven’t defined the collisions yet. Go back into the checkCollision
method and add this to the collisions dictionary:
..., // In this case, ... is the last element. Add the comma, it's important!
"killu": [0, []] // The type is "killu". I know, I know. I wrote this when I was 12 and wasn't motivated enough to change the names.
Your collisions dict should now look like this:
var collisionsDict = {
"solid": [0, []], // Remember the word "solid" from when you created a brick? This references that!
"allBricks": [0, []], // Each entry stores an array containing a number (the number of things in it) and another array, the things themselves.
"allPlayers": [0, []], // Every player in the collision. Above is every block.
"all": [0, []],
"killu": [0, []] // The type is "killu". I know, I know. I wrote this when I was 12 and wasn't motivated enough to change the names. // Everything.
}
And if you run, it should work; you will however not collide with the lava; you’ll pass right through it. This is because there’s no collisions rule yet, so in the Player constructor add this line at the very end:
this.specialCollisions.push("killu"); // Register killu as a special collision type
Now, define a specialCollision
method on the Player
class. It should take one argument: type
, this is just defining the function you’ve already started using in special collisions. You can put an alert
statement in there just to make sure it works: this is called bug prevention. Your function should thus look like this:
specialCollision(type){
alert(type);
}
This should alert (“killu”) when you run into the lava, if not, your code is broken. In fact, it’ll alert it again. And again. And again. Over and over and over, until you exit the tab or just ignore it. This is because you never die! We need to add death now. Create a method on Game
called die
, which takes no arguments, like so:
die(){
}
(Yeah, yeah, I know we’ve been through this and you know the drill, just putting that in for clarity.)
The die
function should first do a complex little forEach
over the tileset:
this.tileset.forEach((item, index) => {
item.remove();
});
We haven’t defined a remove function for blocks yet, unfortunately, so nothing but errors will happen; we can fix this by defining it on the Brick
class like so:
remove(){
this.element.parentNode.removeChild(this.element);
}
This is gnarly, so let’s walk through it:
this.element
: This is the element..parentNode
: This is the parent element; the one we ran appendChild
on. It’s div#game
, if you like CSS selectors..removeChild(
: all elements support this function, it removes a set child element.this.element
: We want to remove this element from it’s parent, this does it successfully.Finally, in the Player
specialCollision
function, add this:
if (type == "killu"){
this.game.die();
}
This just checks if it hit a killu, then if it did, ends the game: all the blocks disappear. They’re still there, however, simply no longer drawing. We need to erase the actual bricks themselves so they can no longer collide; if you create a new brick, you’ll be able to use it as reference to see that the player still collides with the old ones. We must actually delete the content of the array after the forEach
:
this.tileset.splice(0, this.tileset.length);
This simply splices away the entire list. If you don’t understand how splicing works, you can check at our favorite developer site MDN.
If you run it now and die, you’ll notice that the player’s y
(game.player.y
in the console) is increasing ridiculously - something it wouldn’t do if there was still a merely hidden brick under it.
We must finally erase the player; however, we don’t want to actually delete it, we want to reuse it. First, define a endGame
function on the player, like so:
endGame(){
this.element.style.display = "none"; // This sets the css property display to none, hiding it and making it inactive.
}
Call it after you erase the tileset: this.player.endGame()
.
Now, when you reload and hit lava, you die! If you want to make a healthbar, go ahead; I don’t intend to add that to Platformer from Scratch, however.
Start by defining a CSS rule for .coin
which sets background-color
to yellow
. It should look like this:
.coin{
background-color: yellow;
}
If you were wondering how I produced the current coin graphics, it’s very complex CSS and we won’t visit that in this chapter.
Now, create a coin the way you created the lava but with tencoin
as the type; here’s the test level:
game.create(-2, 4, 7, 1, "normal", "solid");
game.create(-2, 3, 1, 1, "normal", "solid");
game.create(4, 3, 1, 1, "normal", "solid");
game.create(3, 3, 1, 1, "lava", "killu");
game.create(2, 3, 1, 1, "coin", "tencoin");
Hit it, and the engine breaks again. What do you think the problem is? Hint: it’s the same issue as we had with Lava. Scroll down for the fix.
The issue is that we never added tencoin
to our collision code; the fixed collisions dict looks like this:
var collisionsDict = {
"solid": [0, []], // Remember the word "solid" from when you created a brick? This references that!
"allBricks": [0, []], // Each entry stores an array containing a number (the number of things in it) and another array, the things themselves.
"allPlayers": [0, []], // Every player in the collision. Above is every block.
"all": [0, []], // Everything
"killu": [0, []], // The type is "killu". I know, I know. I wrote this when I was 12 and wasn't motivated enough to change the names.
"tencoin": [0, []]
}
Now, when you bump the coin, you simply go through it like you did with the lava. We need to add special collision code for it! You can do this the way you did for lava in the Player
constructor, like this: this.specialCollisions.push("tencoin") // Add ten coins to special collisions
.
We can now actually run collisions on tencoins in the specialCollision
function:
... // This, as always, signifies the old code
if (type == "tencoin"){
}
And then… what? We can’t put anything there because we don’t have a reference to the brick. So, let’s make the PhysicsObject
doCollision
code pass us a Brick object. First, in doCollision
, edit the call to this.specialCollision
by passing coll[item][1]
to it, like so:
this.specialCollision(item, coll[item][1]);
Now, we have access to a list of bricks from the specialCollision
function! Add a new argument to the constructor, items
, and we can do a forEach
inside the if (type == "tencoin")
, like this:
items.forEach((item, index) => {
this.game.deleteBrick(item);
});
Your full specialCollision function should now look like this:
specialCollision(type, items){
if (type == "killu"){
this.game.die();
}
if (type == "tencoin"){
items.forEach((item, index) => {
this.game.deleteBrick(item);
});
}
}
If you observe, you’ll notice that there is no deleteBrick
function on Game
. Let’s add that now; I’ll let you figure out how to add a function that takes an argument brick
as we’ve done this many times over. Inside the function, add brick.remove();
(because brick
is a normal Brick
object), and you can now try running the program. The coin will delete when you touch it, and the game will crash; this is because you keep touching the virtual brick and it’s trying to remove graphics that don’t exist any more. We can fix this by adding this.tileset.splice(this.tileset.indexOf(brick), 1);
in front of brick.remove
in the deleteBrick
function. This is gnarly, so let’s go over it step by step:
this.tileset
: Our tileset..splice
: If you read the MDN article referenced above, you’ll know.this.tileset.indexOf(brick)
: Ah yes something new. This finds the index in an array of an element. If you have an array likevar myArray = ["a", "b", "c"];
and you run myArray.indexOf("b")
, you’ll get 1; if you run myArray.indexOf("d")
, you’ll get -1 which signifies that it doesn’t exist. For more, view the most acclaimed MDN article., 1
: Splice for one index only, so we only delete one brick. We can at this point let the garbage collector do the rest and know that, being no longer held anywhere, the Brick object will simply be truly deleted.Note: If you’re uncommonly observant, you might notice that the brick in question is probably not going to be deleted immediately because it’s stored in a coll
array somewhere. To put it simply, that coll
array will be eventually thrown out too, so you don’t have a memory leak. Your deleteBrick
code should look like this now:
deleteBrick(brick){
brick.remove();
this.tileset.splice(this.tileset.indexOf(brick), 1);
}
Reload and run into the coin - no errors, it deletes! You don’t have a score counter yet, however, so let’s implement that. Store a value this._score
to 0
in Player
’s constructor
, like this: this._score = 0;
. The reason why we use _score
instead of score
is a fairly obscure (yet very well-known) Javascript feature: in Javascript, on a class, you can define special functions that run every time you try accessing or editing a value on an object of that class. These are called “getters” and “setters”, and they can be used to run a code, such as updating the score on screen every time the score is updated. If you still don’t understand, here’s the beautiful MDN articles on the subject: getter, setter. We define the score
getter like so:
get score(){
return this._score;
}
Nice and easy, right? Since _score
is the actual variable that gets edited, we can just return it. The reason we need this extra _score
is so we can define a setter:
set score(val){
this._score = val;
this.element.innerHTML = this._score;
}
(These should both be on the player class). You can test it like this in the console: game.player.score = 10;
, you should see a score counter appear! Your player class should now look like this (yes, I disabled selecting):
class Player extends PhysicsObject{
constructor(game, x, y, width, height){
super(game, x, y, width, height);
this.element = document.createElement("div");
document.getElementById("game").appendChild(this.element);
this.element.classList.add("player");
this.draw();
this.keysHeld = {}; // {} means a new dictionary-like object.
document.addEventListener("keydown", (event) => {
this.keysHeld[event.key] = true;
});
document.addEventListener("keyup", (event) => {
this.keysHeld[event.key] = false;
});
this.specialCollisions.push("killu"); // Register killu as a special collision type
this.specialCollisions.push("tencoin") // Add ten coins to special collisions
this._score = 0;
}
set score(val){
this._score = val;
this.element.innerHTML = this._score;
}
get score(){
return this._score;
}
draw(){
this.element.style.left = (window.innerWidth - this.width) / 2 + "px";
this.element.style.top = (window.innerHeight - this.height) / 2 + "px";
this.element.style.width = this.width + "px";
this.element.style.height = this.height + "px";
}
Jump(){
if (this.touchingBottom){
this.yv = -20;
}
}
Left(){
this.xv -= 3;
}
Right(){
this.xv += 3;
}
loop(){
super.loop();
if (this.keysHeld["ArrowUp"]){
this.Jump();
}
if (this.keysHeld["ArrowLeft"]){
this.Left();
}
if (this.keysHeld["ArrowRight"]){
this.Right();
}
}
specialCollision(type, items){
if (type == "killu"){
this.game.die();
}
if (type == "tencoin"){
items.forEach((item, index) => {
this.game.deleteBrick(item);
});
}
}
endGame(){
this.element.style.display = "none"; // This sets the css property display to none, hiding it and making it inactive.
}
}
Now, we can change up the Player
specialCollision
function to increment the score counter when it touches a coin, by adding this.score += 10;
in the deletion loop, like so:
if (type == "tencoin"){
items.forEach((item, index) => {
this.game.deleteBrick(item);
this.score += 10;
});
}
Run the game, hit the coin, and…. You get a score counter and ten points!
Your code works well, however, the coin is pretty ugly, so let’s fix the CSS a bit. Go into main.css
and, under .tencoin
, add this rule: border-radius: 100%
. Border-radius rounds the edges of any element, so you can turn rectangles into circles; 100% means it rounds it completely. Your coin style should now look like this:
.coin{
background-color: yellow;
border-radius: 100%;
}
It’s hard to understand the coin type without having some text, so let’s add this in the Brick
constructor:
if (type == "tencoin"){
this.element.innerHTML = "<span>10</span>";
}
(This should be after element create code). This creates a new span
element with the content “10” and puts it inside the coin element; if you run it now, you should see the “10” on the coin, but it looks absolutely contemptible. Let’s add this little piece of CSS to a new rule .coin > span
(which controls that span), like so:
.coin > span{
display: inline-block;
position: relative; /* Relative is easier to read than absolute in this case - it should be placed *relative* to the outer brick */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
Yay, now the “10” is centered! We’ll add 50 coins, animation, and the rest of the style in the next chapter; for now, you should play with it to try and make it your own.
Right now, we have fixed-position bricks that don’t do a lot, but our physics engine is much more powerful than that! Let’s start on enemies. Define a new class extending Brick
called NormalEnemy
, like so:
class NormalEnemy extends Brick{
constructor(game, x, y, width, height, style, type, config){
}
}
We can ignore config
for now - we’ll use it for things like speed later - but we can use all the other arguments in super
, like this: super(game, x, y, width, height, style, type);
, this ensures that the player will collide with it as a lava. Also, add this.xv = 5
, so that it starts off moving, and this.friction = 1;
so it doesn’t decelerate. Finally, make it not static with this.isStatic = false
; otherwise no physics code will run. Note that our NormalEnemy
can be any brick type, we want it to usually be lava killu
. It should look like this:
class NormalEnemy extends Brick{
constructor(game, x, y, width, height, style, type, config){
super(game, x, y, width, height, style, type);
this.xv = 5;
this.friction = 1;
this.isStatic = false;
}
}
Now we need to revamp our create commands. Rather than explain all the tedious code, I’ll just give it to you with comments; because this isn’t distinctly related to the rest and is fairly complex, I don’t want to waste 500 words on it. Here’s your new create function code:
_create(x, y, width, height, style, type, bricktype = Brick){ // Pass a class as an argument, so we can pass custom classes.
var b = new bricktype(this, x, y, width, height, style, type, true); // Create a new "bricktype", which is by default equal to Brick.
this.tileset.push(b); // Add it to the tileset
return b; // Return it, so you can call this function and then do operations immediately.
}
create(x, y, width, height, style, type, bricktype = Brick){ // Same thing pretty much but uses _create.
return this._create(x * this.blockWidth, y * this.blockHeight, width * this.blockWidth, height * this.blockHeight, style, type, bricktype);
}
Where you do your test level, add a new game.create(<x>, <y>, 1, 1, "lava", "killu", NormalEnemy);
. You might want to add a bit more to the level to make it more suitable for a player to survive without being hit; here’s the test level I’m using:
// Demo
var game = new Game(50, 50);
game.create(-2, 4, 14, 1, "normal", "solid");
game.create(-2, 3, 1, 1, "normal", "solid");
game.create(4, 3, 1, 1, "normal", "solid");
game.create(11, 3, 1, 1, "normal", "solid");
game.create(8, 2, 1, 1, "lava", "killu", NormalEnemy);
function mainloop(){
game.loop();
window.requestAnimationFrame(mainloop);
}
window.requestAnimationFrame(mainloop);
Run it and you should see a weird red brick fall and then push into the wall. It doesn’t bounce back! That’s because we haven’t defined any collision functions; let’s do that now. In NormalEnemy
add the functions hitLeft
and hitRight
like below:
hitLeft(){
this.xv *= -1;
}
hitRight(){
this.xv *= -1;
}
Both of these just do the same thing, this is because of the way we defined our physics engine: if you want to catch collisions on the left and collisions on the right, you have to define for both. The *= -1
just reverses it: if it’s positive, it becomes negative, if it’s negative, it becomes positive. If you try it… Nothing happens! That’s because when you collide, xv
is set to 0. Let’s add two new physics options to PhysicsObject (just store the value on the object), like this:
this.zeroOnHitX = true;
this.zeroOnHitY = true;
And now, where you set this.xv = 0;
and this.yv = 0;
, wrap them in an if
statement like this:
if (this.zeroOnHit/*X or Y depending on if this is Xv or Yv*/){
// the old zero code.
}
I’ll be nice and give you the full PhysicsObject code now:
class PhysicsObject{
constructor(game, x, y, width, height, isStatic){
this.game = game;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.xv = 0;
this.yv = 0;
this.gravity = 1;
this.friction = 0.8;
this.isStatic = isStatic;
this.collisions = ["solid"]; // Solid is always a collision!
this.specialCollisions = []; // No default special collisions.
this.zeroOnHitX = true;
this.zeroOnHitY = true;
}
loop(){
if (!this.isStatic){
this.touchingTop = false;
this.touchingBottom = false;
this.touchingLeft = false;
this.touchingRight = false;
this.xv *= this.friction;
this.yv += this.gravity;
this.move(this.xv, 0);
var collX = this.doCollision(this.game.checkCollision(this));
if (collX[0]){
while (this.doCollision(this.game.checkCollision(this, collX[1]))[0]){
this.move(-Math.abs(this.xv)/this.xv, 0);
}
if (this.xv > 0){ // Positive velocity = moving right
this.touchingRight = true;
this.hitRight();
}
else if (this.xv < 0){ // Negative velocity = moving left
this.touchingLeft = true;
this.hitLeft();
}
if (this.zeroOnHitX){
this.xv = 0;
}
}
this.move(0, this.yv);
var collY = this.doCollision(this.game.checkCollision(this));
if (collY[0]){
while (this.doCollision(this.game.checkCollision(this, collY[1]))[0]){
this.move(0, -Math.abs(this.yv)/this.yv);
}
if (this.yv > 0){ // Positive velocity = moving down
this.touchingBottom = true;
this.hitBottom();
}
else if (this.yv < 0){ // Negative velocity = moving up
this.touchingTop = true;
this.hitTop();
}
if (this.zeroOnHitY){
this.yv = 0;
}
}
}
}
doCollision(coll){
var returner = [false, []];
this.collisions.forEach((item, i) => {
if (coll[item][0] > 0){
returner[0] = true;
returner[1].push(...coll[item][1]); // This is unpacking magic.
}
});
this.specialCollisions.forEach((item, index) => {
if (coll[item][0] > 0){
this.specialCollision(item, coll[item][1]);
}
});
return returner;
}
move(xm, ym){
this.x += xm;
this.y += ym;
}
hitBottom(){
}
hitTop(){
}
hitLeft(){
}
hitRight(){
}
specialCollision(type){
}
}
Of course, I disabled selecting. Now that we have this, let’s add to the NormalEnemy constructor: this.zeroOnHitX = false;
, so it doesn’t zero and you rebound properly. Note that we don’t want to set this.zeroOnHitY
, if we do the enemy will perform well until it suddenly falls out of the platform because it never zeroed YV and eventually got moving so fast it went right through it. Reload the code and… It bounces and moves! Try hitting it… you die! Yay, our first enemy!
In this chapter, we made coins and custom bricks, and finished the physics engine. Most of our code is actually finished, so in chapter 5 we’ll finish adding the rest of the features:
And in chapter 6, we’ll finish the series with a full level menu and saved games. For now, play with it a bit and hopefully add some more features of your own; see you in chapter 5!
As always, you can view the code for this chapter on Github.
My friend's blogs: Wizardwatch's overall site, Sawyer's blog (the .org part bemuses me), Luke's site. If ryleu decides to actually put something on his site, I'll link it here.