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.

Okay, the title is a bit misleading, what is the point of creating a game using Arcade Physics by removing Arcade Physics?

In this case, because we use so little Arcade physics functionality that it is more interesting to remove it and manage the square movement on our own.

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.

When we calculated pixel perfect collisions we already created our own routine instead of using Arcade physics, this is an opportunity to do everything on our own just based on the delta time between frames.

At this point, the tween used for the square animation was also removed, always using the delta time between frames.

This is the result, and you should see no difference between this prototype and the prototype using Arcade physics.

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 without any physics engine.

The code has been a bit rearranged and split into more classes, one extending Sprite class to handle physics and another to keep all collision related methods, so now we have one HTML file, one CSS file and seven 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]
}

// 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';
import { PhysicsSquare } from './physicsSquare';
import { CollisionUtils } from './collisionUtils'

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

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

    level : number;
    floorLevel : number;
    theSquare : PhysicsSquare
    backgroundGroup : Phaser.GameObjects.Group;
    obstacleGroup : Phaser.GameObjects.Group;
    emitter : Phaser.GameObjects.Particles.ParticleEmitter;
    levelTexts : Phaser.GameObjects.BitmapText[];

    // method to be called once the instance has been created
    create() : void {
        this.backgroundGroup = this.add.group();
        this.obstacleGroup = this.add.group();
        this.level = 0;
        this.floorLevel = 0;
        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 = new PhysicsSquare(this);
        this.theSquare.setDepth(1);
        this.placeSquare();
        this.input.on('pointerdown', this.squareJump, this);  
    }

    update(totalTime : number, deltaTime : number) : void {  

        this.theSquare.move(deltaTime / 1000);

        // get square vertices
        let squareVertices : Phaser.Geom.Point[] = CollisionUtils.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[] = CollisionUtils.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 (CollisionUtils.doPolygonsIntersect(squareVertices, obstacleVertices)) {
                this.destroySquare();
            }

            // 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.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.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);
            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.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);
        this.theSquare.placeOnFloor(Levels[this.level], this.floorLevel);    
    }

    // method to make the square jump
    squareJump() : void {
        this.theSquare.jump();
    }
}

physicsSquare.ts

This class extends Sprite class and adds some basic physics features to a sprite.

// PHYSICSQUARE CLASS EXTENDS PHASER.GAMEOBJECTS.SPRITE

import { GameOptions } from './gameOptions';

export class PhysicsSquare extends Phaser.GameObjects.Sprite {

    velocity : Phaser.Math.Vector2;
    speed : number;
    jumpForce : number;
    gravity : number;
    floorY : number;
    canJump : boolean;
    jumpTween : Phaser.Tweens.Tween;
    rotationDirection : number;

    constructor(scene : Phaser.Scene) {
        super(scene, 0, 0, 'tile');
        scene.add.existing(this);
    }

    placeOnFloor(levelData : any, floorLevel : number) : void {
        this.displayWidth = levelData.floors[floorLevel].square.size;
        this.displayHeight = levelData.floors[floorLevel].square.size;
        this.setTint(levelData.floors[floorLevel].colors.foreground);  
        this.setX((floorLevel % 2 == 0) ? 0 - levelData.floors[floorLevel].square.size : GameOptions.level.width + levelData.floors[floorLevel].square.size);
        this.setY(GameOptions.level.height / levelData.floors.length * (floorLevel + 1) - levelData.floors[floorLevel].square.size / 2);
        this.jumpForce = levelData.floors[floorLevel].square.jumpForce;
        this.gravity = levelData.floors[floorLevel].square.gravity;;
        this.velocity = new Phaser.Math.Vector2(levelData.floors[floorLevel].square.speed * ((floorLevel % 2 == 0) ? 1 : -1), 0);
        this.floorY = this.y;
        this.canJump = true; 
        this.angle = 0;
        this.rotationDirection = floorLevel % 2 == 0 ? 1 : -1;
    }

    jump() : void {
        if (this.canJump) {
            this.canJump = false;
            this.velocity.y = this.jumpForce;
        }
    }

    move(seconds : number) : void {
        this.x += this.velocity.x * seconds;
        this.y -= this.velocity.y * seconds;
        if (this.y < this.floorY) {
            this.angle += (180 / (this.jumpForce / this.gravity * 2)) * seconds * this.rotationDirection;
            this.velocity.y -= this.gravity * seconds;
        }
        this.y = Math.min(this.y, this.floorY);
        if (this.y == this.floorY) {
            this.canJump = true;
            this.angle = 0;    
        }  
    }
}

collisionUtils.ts

A simple collection of utilities to manage collisions through geometry, like a function to get rectangle vertices or check if two polygons overlap.

export class CollisionUtils {

    // method to check if two polygons intersect
    // adapted from https://stackoverflow.com/questions/10962379/how-to-check-intersection-between-2-rotated-rectangles
    static 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
    static 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
                    }
                ]
            }
        ] 
    }
]

Now we have our Space is Key prototype with no Physics engine, but I still want to add one more feature: importing levels from Tiled. I will cover this topic next time, meanwhile download the source code of this project.

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