Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Space is Key game, Game development, HTML5, Javascript, Phaser and TypeScript.

Today I am showing you an improved version of Space is Key HTML5 prototype built with Phaser. The main issue in previous step was the collision management.

All posts in this tutorial series:

Step 1: First TypeScript prototype using Arcade physics and tweens.

Step 2: Creation of some kind of proprietary engine to manage any kind of level

Step 3: Introducing pixel perfect collisions and text messages.

Step 4: Removing Arcade physics and tweens, only using delta time between frames.

Step 5: Using Tiled to draw levels.

Arcade physics works using axis aligned bounding boxes, or AABB. If you aren’t familiar with these words, check this post which also explains continuous collision detection.

Using continuous collision detection in a game like Space is Key is not necessary, but a pixel perfect collision is needed since the square players control rotates when jumping.

Moreover, it’s always fun to learn something new, in this case a routine to determine collision between any type of convex polygons.

The second feature is the capability of adding text messages like in the original game.

Let’s have a look at the prototype:

Jump by clicking or tapping on the canvas (no space key at the moment, ironically), do not hit obstacles. Look at the texts and enjoy pixel perfect collisions.

Since there is no need to reinvent the wheel, I adapted the script found on this Stack Overflow page. It basically uses the Separation Axis Theorem, which says that if we can draw a straight line – called separating line – between two convex shapes, they do not overlap.

The code is pretty straightforward and provides some comments, and its source code consists in one HTML file, one CSS file and five TypeScript files.

index.html

The web page which hosts the game, to be run inside thegame element.

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="initial-scale=1, maximum-scale=1">
        <link rel="stylesheet" href="style.css">
        <script src="main.js"></script> 
    </head>
    <body>   
        <div id = "thegame"></div>
    </body>
</html>

style.css

The cascading style sheets of the main web page.

* {
    padding : 0;
    margin : 0;
}

body {
    background-color: #000000;    
}

canvas {
    touch-action : none;
    -ms-touch-action : none;
}

gameOptions.ts

Configurable game options. It’s a good practice to place all configurable game options, if possible, in a single and separate file, for a quick tuning of the game. There aren’t that much game options here, because most of them are stored in level configuration file: levels.ts.

// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
 
export const GameOptions : any = {

    level : {
        width : 800,
        height : 600
    }
}

main.ts

This is where the game is created, with all Phaser related options.

// MAIN GAME FILE

// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame } from './playGame';

// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
    mode : Phaser.Scale.FIT,
    autoCenter : Phaser.Scale.CENTER_BOTH,
    parent : 'thegame',
    width : 800,
    height : 600
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = { 
    type : Phaser.AUTO,
    backgroundColor : 0x000000,
    scale : scaleObject,
    scene : [PreloadAssets, PlayGame],
    physics : {
        default : 'arcade',
        arcade : {
            gravity : {
                y : 0
            }
        }
    }
}

// the game itself
new Phaser.Game(configObject);

preloadAssets.ts

Here we preload all assets to be used in the game. Actually, just the bitmap font and the tile I am resizing and tinting when needed.

// CLASS TO PRELOAD ASSETS
 
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
 
    // constructor    
    constructor() {
        super({
            key : 'PreloadAssets'
        });
    }
 
    // method to be called during class preloading
    preload() : void {

        // this is how to load an image
        this.load.image('tile', 'assets/sprites/tile.png');

        // this is how to load a bitmap font
        this.load.bitmapFont('font', 'assets/fonts/font.png', 'assets/fonts/font.fnt');

    }
 
    // method to be called once the instance has been created
    create() : void {

        // call PlayGame class
        this.scene.start('PlayGame');
    }
}

playGame.ts

Main game file, all game logic is stored here.

// THE GAME ITSELF

import { GameOptions } from './gameOptions';
import { Levels } from './levels';

// this class extends Scene class
export class PlayGame extends Phaser.Scene {

    constructor() {
        super({
            key : 'PlayGame'
        });
    }

    level : number;
    floorLevel : number;
    theSquare : Phaser.Physics.Arcade.Sprite;
    canJump : boolean;
    backgroundGroup : Phaser.GameObjects.Group;
    groundGroup : Phaser.Physics.Arcade.Group;
    obstacleGroup : Phaser.Physics.Arcade.Group;
    jumpTween : Phaser.Tweens.Tween;
    emitter : Phaser.GameObjects.Particles.ParticleEmitter;
    destroyNextFrame : boolean;
    levelTexts : Phaser.GameObjects.BitmapText[];

    // method to be called once the instance has been created
    create() : void {
        this.backgroundGroup = this.add.group();
        this.groundGroup = this.physics.add.group();
        this.obstacleGroup = this.physics.add.group();
        this.level = 0;
        this.floorLevel = 0;
        this.destroyNextFrame = false;
        this.canJump = true;
        this.drawLevel();
        this.emitter = this.add.particles(0, 0, 'tile', {
            gravityY : 20,
            speed : {
                min : 20,
                max : 50
            },
            scale : {
                min : 0.05,
                max : 0.1
            },
            lifespan : 800,
            alpha : {
                start : 1,
                end: 0
            },
            emitting : false
        });
        this.emitter.setDepth(2);
        this.theSquare = this.physics.add.sprite(0, 0, 'tile');
        this.theSquare.setDepth(1);
        this.placeSquare();
        this.input.on('pointerdown', this.squareJump, this);  
    }

    update() : void {  
        
        // check if it's time to destroy the square
        if (this.destroyNextFrame) {
            this.destroySquare();
        }

        // if it's not time to destroy the square, let's play
        else {

            // adjust square position, needed if the player dies while jumping
            this.theSquare.y = Math.min(this.theSquare.y, this.theSquare.getData('maxY'));

            // get square vertices
            let squareVertices : Phaser.Geom.Point[] = this.getRotatedRectangleVertices(this.theSquare.x, this.theSquare.y, this.theSquare.displayWidth, this.theSquare.displayHeight, this.theSquare.angle) 
       
            // check collision between the square and the obstacles
            this.obstacleGroup.getChildren().forEach((obstacle : any) => {
                let obstacleVertices : Phaser.Geom.Point[] = this.getRotatedRectangleVertices(obstacle.x, obstacle.y, obstacle.displayWidth, obstacle.displayHeight, obstacle.angle)   
                
                // if a collision is detected, then set the square to be destroyed next frame
                if (this.doPolygonsIntersect(squareVertices, obstacleVertices)) {
                    this.destroyNextFrame = true;   
                }

                // handle collision between the square and the ground
                this.physics.collide(this.theSquare, this.groundGroup);

                // check if the square can jump
                let squareBody : Phaser.Physics.Arcade.Body = this.theSquare.body as Phaser.Physics.Arcade.Body;
                if (squareBody.touching.down) {
                    this.canJump = true;
                }

                // check if the square left the screen from the left or from the right according to floor number
                if ((this.theSquare.x > GameOptions.level.width && this.floorLevel % 2 == 0) || (this.theSquare.x < 0 && this.floorLevel % 2 == 1)) {
                    this.moveToNextFloor();
                }
            })
        }
    }

    // method to destroy the square
    destroySquare() : void {
        this.destroyNextFrame = false;
        this.emitter.x = this.theSquare.x;
        this.emitter.y = this.theSquare.y;
        this.emitter.explode(32);
        this.emitter.forEachAlive((particle : Phaser.GameObjects.Particles.Particle) => {
            particle.tint = this.theSquare.tintTopLeft;
        }, this);   
        this.placeSquare();
    }

    // method to move the square onto next floor
    moveToNextFloor() : void {
        this.floorLevel ++;
        if (this.floorLevel == Levels[this.level].floors.length) {
            this.floorLevel = 0;
            this.level ++;
            if (this.level == Levels.length) {
                this.level = 0;
            }
            this.drawLevel();
        }
        this.placeSquare();
    }

    // method to draw a level
    drawLevel() : void {
        this.levelTexts = [];
        this.backgroundGroup.clear(true, true);
        this.groundGroup.clear(true, true);
        this.obstacleGroup.clear(true, true);
        let floorHeight : number = GameOptions.level.height / Levels[this.level].floors.length;
        for (let i : number = 0; i < Levels[this.level].floors.length; i ++) {
            let background : Phaser.GameObjects.TileSprite = this.add.tileSprite(0, floorHeight * i, GameOptions.level.width, floorHeight, 'tile');
            background.setTint(Levels[this.level].floors[i].colors.background);    
            background.setOrigin(0);
            this.backgroundGroup.add(background);
            let floor : Phaser.GameObjects.TileSprite = this.add.tileSprite(- Levels[this.level].floors[this.floorLevel].square.size, floorHeight * i + floorHeight, GameOptions.level.width + 2 * Levels[this.level].floors[this.floorLevel].square.size, 16, 'tile');    
            floor.setOrigin(0);
            this.physics.add.existing(floor);
            this.groundGroup.add(floor);
            let floorBody : Phaser.Physics.Arcade.Body = floor.body as Phaser.Physics.Arcade.Body
            floorBody.pushable = false;
            if (Levels[this.level].floors[i].obstacles) {
                for (let j : number = 0; j < Levels[this.level].floors[i].obstacles.length; j ++) {  
                    let obstacleX : number = (i % 2 == 0) ? Levels[this.level].floors[i].obstacles[j].start : this.game.config.width as number - Levels[this.level].floors[i].obstacles[j].start;
                    let obstacleY : number = floorHeight * i + floorHeight - Levels[this.level].floors[i].obstacles[j].ground - Levels[this.level].floors[i].obstacles[j].height / 2;
                    let spike : Phaser.GameObjects.TileSprite = this.add.tileSprite(obstacleX, obstacleY, Levels[this.level].floors[i].obstacles[j].width, Levels[this.level].floors[i].obstacles[j].height, 'tile');
                    spike.setTint(Levels[this.level].floors[i].colors.foreground); 
                    this.physics.add.existing(spike);
                    let spikeBody : Phaser.Physics.Arcade.Body = spike.body as Phaser.Physics.Arcade.Body;
                    spikeBody.pushable = false;
                    this.obstacleGroup.add(spike);         
                }
            }
            if (Levels[this.level].floors[i].text == undefined) {
                Levels[this.level].floors[i].text = '';    
            }
            let floorText : Phaser.GameObjects.BitmapText = this.add.bitmapText(0, 0, 'font', Levels[this.level].floors[i].text, 30);
            floorText.setX((i % 2 == 0) ? 5 : this.game.config.width as number);
            floorText.setY(floorHeight * i + 4);
            floorText.setOrigin((i % 2 == 0) ? 0 : 1, 0);
            floorText.setTint(Levels[this.level].floors[i].colors.foreground); 
            floorText.setVisible(false);
            this.levelTexts.push(floorText);
        }
    }

    // method to place the square on a floor
    placeSquare() : void {
        this.levelTexts[this.floorLevel].setVisible(true);
        if (this.jumpTween) {
            this.jumpTween.stop();
            this.theSquare.angle = 0;
        }    
        this.theSquare.displayWidth = Levels[this.level].floors[this.floorLevel].square.size;
        this.theSquare.displayHeight = Levels[this.level].floors[this.floorLevel].square.size;
        this.theSquare.setGravityY(Levels[this.level].floors[this.floorLevel].square.gravity);
        this.theSquare.setTint(Levels[this.level].floors[this.floorLevel].colors.foreground);  
        this.theSquare.setVelocityX((this.floorLevel % 2 == 0) ? Levels[this.level].floors[this.floorLevel].square.speed : - Levels[this.level].floors[this.floorLevel].square.speed);
        this.theSquare.setVelocityY(0);
        this.theSquare.setX((this.floorLevel % 2 == 0) ? 0 - Levels[this.level].floors[this.floorLevel].square.size : GameOptions.level.width + Levels[this.level].floors[this.floorLevel].square.size);
        this.theSquare.setY(GameOptions.level.height / Levels[this.level].floors.length * (this.floorLevel + 1) - Levels[this.level].floors[this.floorLevel].square.size / 2);
        this.theSquare.setData('maxY', this.theSquare.y);
        this.canJump = true; 
    }

    // method to make the square jump
    squareJump() : void {
        if (this.canJump) {
            this.canJump = false;
            this.theSquare.setVelocityY(Levels[this.level].floors[this.floorLevel].square.jumpForce * -1);
            let jumpAngle : number = this.floorLevel % 2 == 0 ? 180 : -180;
            this.jumpTween = this.tweens.add({
                targets : this.theSquare,
                angle : this.theSquare.angle + jumpAngle,
                duration : Levels[this.level].floors[this.floorLevel].square.jumpForce / Levels[this.level].floors[this.floorLevel].square.gravity * 2000
            })
        } 
    }

    // method to check if two polygons intersect
    // adapted from https://stackoverflow.com/questions/10962379/how-to-check-intersection-between-2-rotated-rectangles
    doPolygonsIntersect(a : Phaser.Geom.Point[], b : Phaser.Geom.Point[]) : boolean {
        
        let polygons : Phaser.Geom.Point[][] = [a, b];
    
        for (let i : number = 0; i < 2; i ++) {
            let polygon : Phaser.Geom.Point[] = polygons[i];
            for (let j : number = 0; j < polygon.length; j ++) {
    
                // grab 2 vertices to create an edge
                let secondVertex : number = (j + 1) % polygon.length;
                var p1 = polygon[j];
                var p2 = polygon[secondVertex];
    
                // find the line perpendicular to this edge
                let normal : Phaser.Geom.Point = new Phaser.Geom.Point(p2.y - p1.y, p1.x - p2.x);

                // for each vertex in the first shape, project it onto the line perpendicular to the edge
                // and keep track of the min and max of these values
                let minA : number | undefined = undefined;
                let maxA : number | undefined = undefined;
                for (let k : number = 0; k < a.length; k ++) {
                    let projected : number = normal.x * a[k].x + normal.y * a[k].y;
                    if (minA == undefined || projected < minA) {
                        minA = projected;
                    }
                    if (maxA == undefined || projected > maxA) {
                        maxA = projected;
                    }
                }
    
                // for each vertex in the second shape, project it onto the line perpendicular to the edge
                // and keep track of the min and max of these values
                let minB : number | undefined = undefined;
                let maxB : number | undefined = undefined;
                for (let k : number = 0; k < b.length; k ++) {
                    let projected : number = normal.x * b[k].x + normal.y * b[k].y;
                    if (minB == undefined || projected < minB) {
                        minB = projected;
                    }
                    if (maxB == undefined || projected > maxB) {
                        maxB = projected;
                    }
                }
    
                // if there is no overlap between the projects, the edge we are looking at separates the two
                // polygons, and we know there is no overlap
                if ((maxA as number) < (minB as number) || (maxB as number) < (minA as number)) {   
                    return false;
                }
            }
        }
        return true;
    }

    // method to ge the vertices of any rectangle
    getRotatedRectangleVertices(centerX : number, centerY : number, width : number, height : number, angle : number) : Phaser.Geom.Point[] {
        let halfWidth : number = width / 2;
        let halfHeight : number = height / 2;
        let halfWidthSin : number = halfWidth * Math.sin(angle);
        let halfWidthCos : number = halfWidth * Math.cos(angle);
        let halfHeightSin : number = halfHeight * Math.sin(angle); 
        let halfHeightCos : number = halfHeight * Math.cos(angle);
        return [
                new Phaser.Geom.Point(centerX + halfWidthCos - halfHeightSin, centerY + halfWidthSin + halfHeightCos),
                new Phaser.Geom.Point(centerX - halfWidthCos - halfHeightSin, centerY - halfWidthSin + halfHeightCos),
                new Phaser.Geom.Point(centerX - halfWidthCos + halfHeightSin, centerY - halfWidthSin - halfHeightCos),
                new Phaser.Geom.Point(centerX + halfWidthCos + halfHeightSin, centerY + halfWidthSin - halfHeightCos)
        ]
    }
}

levels.ts

I am storing levels information in a separate file.

export const Levels : any = [

    // level 1

    {
        floors : [
        
            // floor 0
            {
                text : 'Look at this text',
                square : {
                    size : 24,
                    speed : 170,
                    gravity : 200,
                    jumpForce : 200                  
                },
                colors : {
                    foreground : 0x6598fd,
                    background : 0x003232
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 400,
                        width : 30,
                        height : 60
                    },
                      {
                        ground : 0,
                        start : 300,
                        width : 30,
                        height : 40
                    },
                    {
                        ground : 0,
                        start : 500,
                        width : 30,
                        height : 40
                    }
                ]
            },

            // floor 1
            {
                text : 'You can edit it and even\nuse more lines',
                square : {
                    size : 30,
                    speed : 310,
                    gravity : 450,
                    jumpForce : 210
                },
                colors : {
                    foreground : 0x003232,
                    background : 0x6598fd
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 560,
                        width : 40,
                        height : 40
                    },
                    {
                        ground : 0,
                        start : 240,
                        width : 40,
                        height : 40
                    }
                ]
            },

            // floor 2
            {
                text : 'or just leave floors\nwithout any text, like next ones',
                square : {
                    size : 24,
                    speed : 170,
                    gravity : 650,
                    jumpForce : 410
                },
                colors : {
                    foreground : 0x6598fd,
                    background : 0x003232
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 400,
                        width : 40,
                        height : 60
                    },
                    {
                        ground : 100,
                        start : 350,
                        width : 10,
                        height : 10
                    },
                    {
                        ground : 100,
                        start : 450,
                        width : 10,
                        height : 10
                    }
                ]
            }
        ] 
    },

    // level 2

    {

        floors : [
        
            // floor 0
            {
                square : {
                    size : 40,
                    speed : 100,
                    gravity : 400,
                    jumpForce : 200
                },
                colors : {
                    foreground : 0x323332,
                    background : 0x973263
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 200,
                        width : 20,
                        height : 20
                    },
                    {
                        ground : 0,
                        start : 400,
                        width : 20,
                        height : 20
                    },
                    {
                        ground : 0,
                        start : 600,
                        width : 20,
                        height : 20
                    }
                ]
            },

            // floor 1
            {
                square : {
                    size : 24,
                    speed : 500,
                    gravity : 450,
                    jumpForce : 210
                },
                colors : {
                    foreground : 0x973263,
                    background : 0x323332
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 400,
                        width : 40,
                        height : 40
                    }
                ]
            },

            // floor 2
            {
                square : {
                    size : 24,
                    speed : 170,
                    gravity : 650,
                    jumpForce : 300
                },
                colors : {
                    foreground : 0x323332,
                    background : 0x973263
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 225,
                        width : 80,
                        height : 30
                    },
                    {
                        ground : 0,
                        start : 400,
                        width : 70,
                        height : 40
                    },
                    {
                        ground : 0,
                        start : 575,
                        width : 80,
                        height : 30
                    },
                ]
            },

            // floor 3
            {
                square : {
                    size : 24,
                    speed : 170,
                    gravity : 100,
                    jumpForce : 100
                },
                colors : {
                    foreground : 0x973263,
                    background : 0x323332
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 400,
                        width : 180,
                        height : 30
                    },
                    {
                        ground : 80,
                        start : 400,
                        width : 70,
                        height : 70
                    }
                ]
            }
        ] 
    },

    // level 3

    {

        floors : [
        
            // floor 0
            {
                square : {
                    size : 60,
                    speed : 120,
                    gravity : 100,
                    jumpForce : 200
                },
                colors : {
                    foreground : 0x3363fc,
                    background : 0xfb96c7
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 400,
                        width : 300,
                        height : 60
                    },
                    {
                        ground : 0,
                        start : 400,
                        width : 60,
                        height : 120
                    }
                ]
            },

            // floor 1
            {
                square : {
                    size : 20,
                    speed : 160,
                    gravity : 1200,
                    jumpForce : 600
                },
                colors : {
                    foreground : 0xfb96c7,
                    background : 0x3363fc
                },
                obstacles : [
                    {
                        ground : 0,
                        start : 200,
                        width : 20,
                        height : 40
                    },
                    {
                        ground : 0,
                        start : 300,
                        width : 20,
                        height : 40
                    },
                    {
                        ground : 0,
                        start : 500,
                        width : 20,
                        height : 40
                    },
                    {
                        ground : 0,
                        start : 600,
                        width : 20,
                        height : 40
                    }
                ]
            }
        ] 
    }
]

And that’s all. Now you can build your Space is Key game, as I am doing. I plan to release the game with about 50 levels next week, meanwhile download the source code of this example.

Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.