Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Risky Road game, Game development, HTML5, Javascript and Phaser.

I have something more to say about Risky Road game, so it’s time to see step 4 of the series.

A little recap: in first step i turned the infinite random terrain generator into a playable prototype, but it was on landscape mode and we know hyper casual games work a lot better on portrait mode, so second step featured a zoom to follow the action while playing in portrait.

Step three was built to let you see some advanced collision features, and now I want to add some extra elements to the terrain, so I am going to introduce randomly generated rocks.

Hitting a rock while driving at high speed often leads to game over because you lose your “diamond”, or whatever kind of stuff you are carrying, so I am sure rocks will make the game a little more difficult and unpredictable.

Rocks will also be managed by object pooling to save resources.

Have a look by yourself:

Tap and hold to accelerate, don’t make the crate fall off the cart. And obviously look at the random rocks.

I used trigonometry to place rocks at a random depth under the terrain, according to terrain segment center and angle.

Look at the completely commented source code:

var game;

var gameOptions = {

    // start vertical point of the terrain, 0 = very top; 1 = very bottom
    startTerrainHeight: 0.5,

    // max slope amplitude, in pixels
    amplitude: 100,

    // slope length range, in pixels
    slopeLength: [150, 350],

    // a mountain is a a group of slopes.
    mountainsAmount: 3,

    // amount of slopes for each mountain
    slopesPerMountain: 6,

    // car acceleration
    carAcceleration: 0.01,

    // maximum car velocity
    maxCarVelocity: 1,

    // rocks ratio, in %
    rocksRatio: 5
}
window.onload = function() {
    let gameConfig = {
        type: Phaser.AUTO,
        backgroundColor: 0x75d5e3,
        scale: {
            mode: Phaser.Scale.FIT,
            autoCenter: Phaser.Scale.CENTER_BOTH,
            parent: "thegame",
            width: 750,
            height: 1334
        },
        physics: {
            default: "matter",
            matter: {
                debug: true,
                debugBodyColor: 0x000000
            }
        },
        scene: playGame
    }
    game = new Phaser.Game(gameConfig);
    window.focus();
}
class playGame extends Phaser.Scene{
    constructor(){
        super("PlayGame");
    }
    create(){

        // creation of pool arrays
        this.bodyPool = [];
        this.rocksPool = [];

        // array to store mountains
        this.mountainGraphics = [];

        // mountain start coordinates
        this.mountainStart = new Phaser.Math.Vector2(0, 0);

        // loop through all mountains
        for(let i = 0; i < gameOptions.mountainsAmount; i++){

            // each mountain is a graphics object
            this.mountainGraphics[i] = this.add.graphics();

            // generateTerrain is the method to generate the terrain. The arguments are the graphics object and the start position
            this.mountainStart = this.generateTerrain(this.mountainGraphics[i], this.mountainStart);
        }

        // method to add the car, arguments represent x and y position
        this.addCar(250, game.config.height / 2 - 70);

        // the car is not accelerating
        this.isAccelerating = false;

        // input management
        this.input.on("pointerdown", this.accelerate, this);
        this.input.on("pointerup", this.decelerate, this);

        // collision check between the diamond and the car. Any other diamond collision is not allowed
        this.matter.world.on("collisionstart", function(event, bodyA, bodyB){
            if((bodyA.label == "diamond" && bodyB.label != "car") || (bodyB.label == "diamond" && bodyA.label != "car")){
                this.scene.start("PlayGame")
            }

        }.bind(this));

        // a text to show when we are flying
        this.flyingText = this.add.text(100, 100, "FLYING!!", {
            fontFamily: "Arial",
            fontSize: 128,
            color: "#FF8800"
        });
        this.flyingText.setVisible(false);

        // variable to count the time flying
        this.flyingTime = 0;

        // this event will check all active collisions
        this.matter.world.on("collisionactive", function(e){

            // no wheels colliding
            this.wheelsColliding = false;

            // a collision made by a pair of bodies
            e.pairs.forEach(function(p){

                // if a colliding body's label is "wheel"...
                if(p.bodyA.label == "wheel" || p.bodyB.label == "wheel"){

                    // at least a wheel is colliding
                    this.wheelsColliding = true;
                }
            }.bind(this))
        }.bind(this))
    }

    // method to generate the terrain. Arguments: the graphics object and the start position
    generateTerrain(graphics, mountainStart){

        // place graphics object
        graphics.x = mountainStart.x;

        // draw the ground
        graphics.clear();

        // array to store slope points
        let slopePoints = [];

        // variable to count the amount of slopes
        let slopes = 0;

        // slope start point
        let slopeStart = new Phaser.Math.Vector2(0, mountainStart.y);

        // set a random slope length
        let slopeLength = Phaser.Math.Between(gameOptions.slopeLength[0], gameOptions.slopeLength[1]);

        // determine slope end point, with an exception if this is the first slope of the fist mountain: we want it to be flat
        let slopeEnd = (mountainStart.x == 0) ? new Phaser.Math.Vector2(slopeStart.x + gameOptions.slopeLength[1] * 1.5, 0) : new Phaser.Math.Vector2(slopeStart.x + slopeLength, Math.random());

        // current horizontal point
        let pointX = 0;

        // while we have less slopes than regular slopes amount per mountain...
        while(slopes < gameOptions.slopesPerMountain){

            // slope interpolation value
            let interpolationVal = this.interpolate(slopeStart.y, slopeEnd.y, (pointX - slopeStart.x) / (slopeEnd.x - slopeStart.x));

            // if current point is at the end of the slope...
            if(pointX == slopeEnd.x){

                // increase slopes amount
                slopes ++;

                // next slope start position
                slopeStart = new Phaser.Math.Vector2(pointX, slopeEnd.y);

                // next slope end position
                slopeEnd = new Phaser.Math.Vector2(slopeEnd.x + Phaser.Math.Between(gameOptions.slopeLength[0], gameOptions.slopeLength[1]), Math.random());

                // no need to interpolate, we use slope start y value
                interpolationVal = slopeStart.y;
            }

            // current vertical point
            let pointY = game.config.height * gameOptions.startTerrainHeight + interpolationVal * gameOptions.amplitude;

            // add new point to slopePoints array
            slopePoints.push(new Phaser.Math.Vector2(pointX, pointY));

            // move on to next point
            pointX ++ ;
        }

        // simplify the slope
        let simpleSlope = simplify(slopePoints, 1, true);

        // loop through all simpleSlope points starting from the second
        for(let i = 1; i < simpleSlope.length; i++){

            // define a line between previous and current simpleSlope points
            let line = new Phaser.Geom.Line(simpleSlope[i - 1].x, simpleSlope[i - 1].y, simpleSlope[i].x, simpleSlope[i].y);

            // calculate line length, which is the distance between the two points
            let distance = Phaser.Geom.Line.Length(line);

            // calculate the center of the line
            let center = Phaser.Geom.Line.GetPoint(line, 0.5);

            // calculate line angle
            let angle = Phaser.Geom.Line.Angle(line);

            // if the pool is empty...
            if(this.bodyPool.length == 0){

                // create a new rectangle body
                let body = this.matter.add.rectangle(center.x + mountainStart.x, center.y, distance, 10, {
                    isStatic: true,
                    angle: angle,
                    friction: 1,
                    restitution: 0,
                    collisionFilter: {
                        category: 2
                    },
                    label: "ground"
                });

                // assign inPool property to check if the body is in the pool
                body.inPool = false;

            }

            // if the pool is not empty...
            else{

                // get the body from the pool
                let body = this.bodyPool.shift();

                // change inPool property
                body.inPool = false;

                // reset, reshape and move the body to its new position
                this.matter.body.setPosition(body, {
                    x: center.x + mountainStart.x,
                    y: center.y
                });
                let length = body.area / 10;
                this.matter.body.setAngle(body, 0)
                this.matter.body.scale(body, 1 / length, 1);
                this.matter.body.scale(body, distance, 1);
                this.matter.body.setAngle(body, angle);
            }

            // should we add a rock?
            if(Phaser.Math.Between(0, 100) < gameOptions.rocksRatio && (mountainStart.x > 0 || i != 1)){

                // random rock position
                let size = Phaser.Math.Between(20, 30)
                let depth = Phaser.Math.Between(0, size / 2)
                let rockX = center.x + mountainStart.x + depth * Math.cos(angle + Math.PI / 2);
                let rockY = center.y + depth * Math.sin(angle + Math.PI / 2);

                // draw the rock
                graphics.fillStyle(0x6b6b6b, 1);
                graphics.fillCircle(rockX - mountainStart.x, rockY, size);

                // if the pool is empty...
                if(this.rocksPool.length == 0){

                    // create a new circle body
                    let rock = this.matter.add.circle(rockX, rockY, size, {
                        isStatic: true,
                        angle: angle,
                        friction: 1,
                        restitution: 0,
                        collisionFilter: {
                            category: 2
                        },
                        label: "rock"
                    });

                    // assign inPool property to check if the body is in the pool
                    rock.inPool = false;
                }
                else{

                    // get the rock from the pool
                    let rock = this.rocksPool.shift();

                    // resize the rock
                    this.matter.body.scale(rock, size / rock.circleRadius, size / rock.circleRadius);

                    // move the rock to its new position
                    this.matter.body.setPosition(rock, {
                        x: rockX,
                        y: rockY
                    });
                    rock.inPool = false;
                }
            }
        }

        // draw the slopes
        graphics.moveTo(0, game.config.height * 2);
        graphics.fillStyle(0x654b35);
        graphics.beginPath();
        simpleSlope.forEach(function(point){
            graphics.lineTo(point.x, point.y);
        }.bind(this))
        graphics.lineTo(pointX, game.config.height * 2);
        graphics.lineTo(0, game.config.height * 2);
        graphics.closePath();
        graphics.fillPath();

        // draw the grass
        graphics.lineStyle(16, 0x6b9b1e);
        graphics.beginPath();
        simpleSlope.forEach(function(point){
            graphics.lineTo(point.x, point.y);
        })
        graphics.strokePath();

        // assign a custom "width" property to the graphics object
        graphics.width = pointX - 1

        // return the coordinates of last mountain point
        return new Phaser.Math.Vector2(graphics.x + pointX - 1, slopeStart.y);
    }

    // method to build the car
    addCar(posX, posY){

        // car is made by three rectangle bodies which will be merged into a compound object
        let floor = Phaser.Physics.Matter.Matter.Bodies.rectangle(posX, posY, 100, 10, {
            label: "car"
        });
        let rightBarrier = Phaser.Physics.Matter.Matter.Bodies.rectangle(posX + 45, posY - 15, 10, 20, {
            label: "car"
        });
        let leftBarrier = Phaser.Physics.Matter.Matter.Bodies.rectangle(posX - 45, posY - 15, 10, 20, {
            label: "car"
        });

        // this is how we create the compound object
        this.body = Phaser.Physics.Matter.Matter.Body.create({

            // array of single bodies
            parts: [floor, leftBarrier, rightBarrier],
            friction: 1,
            restitution: 0
        });

        // add the body to the world
        this.matter.world.add(this.body);

        // the diamond. It cannot fall off the car
        this.diamond = this.matter.add.rectangle(posX, posY - 40, 30, 30, {
            friction: 1,
            restitution: 0,
            label: "diamond"
        });

        // add front wheel. A circle
        this.frontWheel = this.matter.add.circle(posX + 35, posY + 25, 30, {
            friction: 1,
            restitution: 0,
            collisionFilter: {
                mask: 2
            },
            label: "wheel"
        });

        // add rear wheel
        this.rearWheel = this.matter.add.circle(posX - 35, posY + 25, 30, {
            friction: 1,
            restitution: 0,
            collisionFilter: {
                mask: 2
            },
            label: "wheel"
        });

        // these two constraints will bind front wheel to the body
        this.matter.add.constraint(this.body, this.frontWheel, 20, 0, {
            pointA: {
                x: 30,
                y: 0
            }
        });
        this.matter.add.constraint(this.body, this.frontWheel, 20, 0, {
            pointA: {
                x: 45,
                y: 0
            }
        });

        // same thing for rear wheel
        this.matter.add.constraint(this.body, this.rearWheel, 20, 0, {
            pointA: {
                x: -30,
                y: 0
            }
        });
        this.matter.add.constraint(this.body, this.rearWheel, 20, 0, {
            pointA: {
                x: -45,
                y: 0
            }
        });
    }

    // method to accelerate
    accelerate(){
        this.isAccelerating = true;
    }

    // method to decelerate
    decelerate(){
        this.isAccelerating = false;
    }

    update(t, dt){

        // if wheels aren't colliding...
        if(!this.wheelsColliding){

            // add frame delta time to flying time
            this.flyingTime += dt;

            // we can say the car is flying when it's in the air for more than 0.5 seconds
            if(this.flyingTime > 500){

                // show flying text
                this.flyingText.setVisible(true);
            }
        }

        // if wheels aren colliding...
        else{

            // reset flying time
            this.flyingTime = 0;

            // hide flying text
            this.flyingText.setVisible(false);
        }

        // zoom is calculated according to car speed.
        // zoom = 1: no zoom
        // zoom > 1: zoom in
        // zoom < 1: zoom out
        let zoom = 1 - Phaser.Math.Clamp(this.body.speed, 0, 15) / 25

        // zoomTo method allows the camera to zoom at "zoom" ratio in 1000 milliseconds
        // the most important argument is the 4th argument.
        // If set to "false", camera won't adjust its zoom if already zooming.
        this.cameras.main.zoomTo(zoom, 1000, "Linear", false);

        // make the game follow the car
        this.cameras.main.scrollX = this.body.position.x - game.config.width / 4 + game.config.width * (1 - this.cameras.main.zoom);
        this.cameras.main.scrollY = this.body.position.y - game.config.height / 2.2;

        // flyingText too should follow the car
        this.flyingText.x = 100 + this.cameras.main.scrollX;

        // adjust velocity according to acceleration
        if(this.isAccelerating){
            let velocity = this.frontWheel.angularSpeed + gameOptions.carAcceleration;
            velocity = Phaser.Math.Clamp(velocity, 0, gameOptions.maxCarVelocity);

            // set angular velocity to wheels
            this.matter.body.setAngularVelocity(this.frontWheel, velocity);
            this.matter.body.setAngularVelocity(this.rearWheel, velocity);
        }

        // loop through all mountains
        this.mountainGraphics.forEach(function(item){

            // if the mountain leaves the screen to the left...
            if(this.cameras.main.scrollX > item.x + item.width + game.config.width){

                // reuse the mountain
                this.mountainStart = this.generateTerrain(item, this.mountainStart)
            }
        }.bind(this));

        // get all bodies
        let bodies = this.matter.world.localWorld.bodies;

        // loop through all bodies
        bodies.forEach(function(body){

            // if the body is out of camera view to the left side and is not yet in the pool..
            if(this.cameras.main.scrollX > body.position.x + game.config.width && !body.inPool){

                // ...add the body to proper pool
                switch(body.label){
                    case "ground":
                        this.bodyPool.push(body);
                        break;
                    case "rock":
                        this.rocksPool.push(body);
                        break;
                }
                body.inPool = true;
            }
        }.bind(this))
    }

    // method to apply a cosine interpolation between two points
    interpolate(vFrom, vTo, delta){
        let interpolation = (1 - Math.cos(delta * Math.PI)) * 0.5;
        return vFrom * (1 - interpolation) + vTo * interpolation;
    }
}

If you compare the source code with the ones in previous editions, I optimized a bit the object pooling by using only one array.

The hard work of optimizing the number of polygons used to generate the terrain is done by Simplify.js library.

Now you have a random generated terrain with hills, with scattered rocks here and there. Download the source code.

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