Significant Feature Enhancement

As part of the enemies group, we decided to make our significant feature enhancement a Final Boss level that will come after all levels.

This blog shows how we incorporated JavaScript Objects, Finite State Machines, and Single Responsibility Principle, while also showing an overarching structure of the entire framework of the platformer3x repository.

JavaScript Objects

But first, what is a JavaScript Object?

To our knowledge, a JavaScript object is an object that has a collection of properties. These are referenced using key:value pairs.

In our boss level (and the whole game altogether), JavaScript objects get utilized quite frequently. They’re used in enemies, players, game elements, and so much more!

We can see this when everything gets called to instantiate the level in GameSetup.js

{ name: 'boss', id: 'boss', class: Boss, data: this.assets.enemies.boss, xPercentage: 0.5, minPosition: 0.3 },

Here, we are creating an object with multiple properties. Let’s go step by step and see what each of them do.

  • name: human readable name for the object that shows up on the display
  • id: identifier used within the code of the game
  • class: defines behavior of the object (so how the boss enemy functions)
  • data: the actual spritesheet of the boss enemy
  • Xpercentage: horizontal position of object relative to canvas size
  • minPosition: minimum horizontal position the boss can move to (0.3 in this case)

Overall, we can see the importance of JavaScript objects within JS code and how it gets used to boil down a complex object to make everything more centralized and easier to work with.

Finite State Machines

As per our understanding, a Finite State Machine (FSM) is a machine that can only be in one state out of a finite number of states. Without the fluff, all this means is that it can only be in one state at a time.

We used this when defining different rows and columns for our sprites when they were in different states. Using our Random Event, the Boss has to be in 1 of 7 states (finite number of states).

Those were:

  • idleL
  • idleR
  • left
  • right
  • attackL
  • attackR
  • death

Each of these states had rows and columns assigned to them as per our spritesheet. These were all defined in GameSetup.js as follows.

boss: {
	src: "/images/platformer/sprites/boss.png",
	width: 64,
	height: 64,
	scaleSize: 320,
	speedRatio: 0.2,
	animationSpeed: 6,
	idleL: { row: 9, frames: 0, idleFrame: { column: 1, frames: 0 } },
	idleR: { row: 11, frames: 0, idleFrame: { column: 1, frames: 0 } },
	left: { row: 9, frames: 8, idleFrame: { column: 7, frames: 0 } },
	right: { row: 11, frames: 8, idleFrame: { column: 7, frames: 0 } },
	attackL: { row: 13, frames: 5 },
	attackR: { row: 15, frames: 5 },
	death: { row: 20, frames: 5 },
},

Each state here has a specific action attached to it. For example, the idleR state has the following:

{ row: 11, frames: 0, idleFrame: { column: 1, frames: 0 } },

This indicates the action for each state. The states for the boss get called in Boss.js where all the actions and behaviors for the Boss are defined.

First, it checks whether the state is dead or not and assigns accordingly.

updateFrameX() {
// Update animation frameX of the object
	if(!this.death  ||  this.state.animation  !=  "death"){
		if (this.frameX  <  this.maxFrame) {
			if(this.counter  >  0){
				this.frameX  =  this.frameX;
				this.counter--;
			}
			else {
				this.frameX++
				this.counter  =  this.animationSpeed;
			}
		} else {
			this.frameX  =  this.minFrame;
		}
	}
	else  if(this.death  &&  this.state.animation  ==  "death"){
		this.animationSpeed  =  50;
		if (this.frameX  <  this.maxFrame) {
			if(this.counter  >  0){
				this.frameX  =  this.frameX;
				this.counter--;
			}
			else{
				this.frameX++
				this.counter  =  this.animationSpeed;
			}
		} else {
			this.destroy();
		}
	}
}

We then check for the other states in the randomEvent method still in Boss.js.

randomEvent(){
	if (GameControl.randomEventId  ===  1  &&  GameControl.randomEventState  ===  2){ //event: stop the zombie
		this.state.direction  =  "left";
		this.state.animation  =  "idleL";
		GameControl.endRandomEvent();
	}
	else if (GameControl.randomEventId  ===  2  &&  GameControl.randomEventState  ===  2){ //event: stop the zombie
		this.state.direction  =  "right";
		this.state.animation  =  "idleR";
		GameControl.endRandomEvent();
	}
	else if (GameControl.randomEventId  ===  3  &&  GameControl.randomEventState  ===  2){ //event: stop the zombie
		this.state.direction  =  "left";
		this.state.animation  =  "left";
		GameControl.endRandomEvent();
	}
	else if (GameControl.randomEventId  ===  4  &&  GameControl.randomEventState  ===  2){ //event: stop the zombie
		this.state.direction  =  "right";
		this.state.animation  =  "right";
		GameControl.endRandomEvent();
	}
}

We also see Finite State Machines applied in Enemy.js.

initEnvironmentState = {
        // Enemy
        animation: 'right', //current animation
        direction: 'right', //facing direction, only contain left or right
        isDying: false,
};

this.state = {...this.initEnvironmentState}; // Enemy and environment states 

In Enemy.js

Just like the player, we can set up a Finite State Machine to the Enemy. We create a variable/object called ‘initEnvironmentState’ that contains a list of variables and in this case, are ‘animation’, ‘direction’, and ‘isDying”.

Inside the constructor, we set up a property called “this.state” and assign ‘initEnvironmentState’ to it.

This is done so that when we call ‘this.state.animation’, it will return the current animation of the enemy. This is the same as ‘this.state.direction’, and ‘this.state.isDying’.

Overall, it helps us to organize the property such that it can only have one state, and when each state gets called / assigned, a new action occurs.

Single Responsibility Principle

This is the principle that every function, every method should have only one function.

Let’s examine how refactoring a method to adhere to SRP can simplify code maintenance and improve flexibility, especially when extending a class. Here’s the original code snippet with a complex update() method:

update() {
    super.update();
    this.setAnimation(this.state.animation);

    // Check for boundaries
    if (this.x <= this.minPosition || (this.x + this.canvasWidth >= this.maxPosition)) {
        if (this.state.direction === "left") {
            this.state.animation = "right";
            this.state.direction = "right";
        } else if (this.state.direction === "right") {
            this.state.animation = "left";
            this.state.direction = "left";
        }
    }

    // Update movement
    if (this.state.animation === "right") {
        this.speed = Math.abs(this.speed);
    } else if (this.state.animation === "left") {
        this.speed = -Math.abs(this.speed);
    } else if (this.state.animation === "idle") {
        this.speed = 0;
    } else if (this.state.animation === "death") {
        this.speed = 0;
    }

    // Move the enemy
    this.x += this.speed;

    this.playerBottomCollision = false;
}

This code does many things at once: setting animations, checking boundaries, and updating movement. To improve it, we can extract specific responsibilities into dedicated methods, as shown below:

checkBoundaries() {
    if (this.x <= this.minPosition || (this.x + this.canvasWidth >= this.maxPosition)) {
        if (this.state.direction === "left") {
            this.state.animation = "right";
            this.state.direction = "right";
        } else if (this.state.direction === "right") {
            this.state.animation = "left";
            this.state.direction = "left";
        }
    }
}

updateMovement() {
    if (this.state.animation === "right") {
        this.speed = Math.abs(this.speed);
    } else if (this.state.animation === "left") {
        this.speed = -Math.abs(this.speed);
    } else {
        this.speed = 0;
    }

    // Move the enemy
    this.x += this.speed;

    this.playerBottomCollision = false;
}

update() {
    super.update();
    this.setAnimation(this.state.animation);

    this.checkBoundaries();
    this.updateMovement();
}

Now, each method has a single responsibility, making it easier to read, understand, and maintain. This refactoring provides a few key benefits:

  • Clarity: The code is easier to understand since each method does one thing. This makes it simpler to follow the logic and identify specific responsibilities.

  • Modularity: By isolating functionality into separate methods, it’s easier to change or extend specific behavior without affecting the rest of the class.

  • Extensibility: If you’re creating a subclass that extends Enemy, you can easily override individual methods like updateMovement() to change behavior without needing to modify the original class. This flexibility is crucial in object-oriented programming, allowing for specialized behavior in subclasses.

For example, if we have a new Enemy that extends the Enemy class, and we want it to have a new movement instead of using the old one. We can just overwrite the method “updateMovement()” by creating a function that has the same name called “updateMovement()” and just put the code we want to overwrite into the new function.

We don’t need to go back and change the Enemy class such as adding an if-statement to the code inside the update() function that manages the movement.

Game Control Code

There are many different objects in the game. This blog will focus on the elements pertaining to the boss level and how they interact with one another to create a game that is playable by a user.

We initialize the JavaScript Objects in GameSetup.js. We do this when we create an array of BossObjects, and then we call the constructor for the boss level.

In GameSetup.js and GameSetterBoss.js


function GameLevelSetup(GameSetter, path, callback, passive = false) {
      var gameObjects = new GameSet(GameSetter.assets, GameSetter.objects, path);
      return new GameLevel({ tag: GameSetter.tag, callback: callback, objects: gameObjects.getGameObjects(), passive: passive });
}

const objects = [
    // GameObject(s), the order is important to z-index...
    { name: 'bossbackground', id: 'background', class: BackgroundParallax, data: assets.backgrounds.boss },
    { name: 'devil', id: 'devil', class:BackgroundParallax, data: assets.backgrounds.devil},
    { name: 'boss', id: 'boss', class: Boss, data: assets.enemies.boss, xPercentage: 0.5, minPosition: 0.3 },
    { name: 'boss1', id: 'boss', class: Boss, data: assets.enemies.boss, xPercentage: 0.3, minPosition: 0.07 },
    { name: 'itemBlock', id: 'jumpPlatform', class: BossItem, data: assets.platforms.itemBlock, xPercentage: 0.2, yPercentage: 0.65 }, //item block is a platform
    { name: 'mario', id: 'player', class: PlayerBoss, data: assets.players.mario },
    { name: 'zombie', id: 'player', class: PlayerZombie, data: assets.players.zombie },
    { name: 'grass', id: 'platform', class: Platform, data: assets.platforms.grass },
    { name: 'tube', id: 'finishline', class: FinishLine, data: assets.obstacles.tube, xPercentage: 0.85, yPercentage: 0.855 },
    { name: 'iceminiEnd', id: 'background', class: BackgroundTransitions, data: assets.transitions.iceminiEnd },
  ];

  const GameSetterBoss = {
    tag: 'Boss',
    assets: assets,
    objects: objects
  };

GameLevelSetup(GameSetterBoss, this.path, this.playerOffScreenCallBack);

We first initialize the JavaScript Object for the level in GameSetterlevel.js by calling the assets defined above in the file.

We then save it as a constant, with the tag being a name used to identify the level, the assets being the images and spritesheets defined above, and objects being the JavaScript object that we made.

Then, to make it into a GameLevel we call the constructor by running the GameLevelSetup function.

Calling that function will return a GameLevel. How it does that is defined in the constructor for the GameLevel class in GameLevel.js

constructor(levelObject) {
        // The levelObjects property stores the levelObject parameter.
        this.levelObjects = levelObject;        
        // The tag is a friendly name used to identify the level.
        this.tag = levelObject?.tag;
        // The passive property determines if the level is passive (i.e., not playable).
        this.passive = levelObject?.passive;
        // The isComplete property is a function that determines if the level is complete.
        // build conditions to make determination of complete (e.g., all enemies defeated, player reached the end of the screen, etc.)
        this.isComplete = levelObject?.callback;
        // The gameObjects property is an array of the game objects for this level.
        this.gameObjects = this.levelObjects?.objects || [];
        // Each GameLevel instance is stored in the GameEnv.levels array.
        GameEnv.levels.push(this);
    }

We see that everything we have passed in, like tag and objects get used here, and finally, we push all the properties to the array defined in GameEnv.


In GameEnv, we can see a static array that has been defined that contains all the levels.

In GameEnv.js

static levels = [];

But first, to fully understand everything, we need to look at the js gameLoop() in GameControl.js.

gameLoop() {
        // Turn game loop off during transitions
        if (!this.inTransition) {

            // Get current level
            GameEnv.update();
            const currentLevel = GameEnv.currentLevel;

            // currentLevel is defined
            if (currentLevel) {
                // run the isComplete callback function
                if (currentLevel.isComplete && currentLevel.isComplete()) {
                    const currentIndex = GameEnv.levels.indexOf(currentLevel);
                    // next index is in bounds
                    if (currentIndex !== -1 && currentIndex + 1 < GameEnv.levels.length) {
                        // transition to the next level
                        this.transitionToLevel(GameEnv.levels[currentIndex + 1]);
                    } 
                }
            // currentLevel is null, (ie start or restart game)
            } else {
                // transition to beginning of game
                this.transitionToLevel(GameEnv.levels[0]);
            }
        }
    }

First, the Loop checks whether we are transitioning to a new level or not. If we are, it won’t execute any of the following code.

But if we aren’t, we start by updating the Game by running GameEnv.update().

When we find the update() function in GameEnv, we see that it draws all the GameObjects defined in the level and updates.

static update() {
        // Update game state, including all game objects
        // if statement prevents game from updating upon player death
        if (GameEnv.player === null || GameEnv.player.state.isDying === false) {
            for (const gameObject of GameEnv.gameObjects) {
                gameObject.update();
                gameObject.serialize();
                gameObject.draw();
            } 
        }
    }

Then, we store the current level under a constant and check if that value is not null. If it’s null, it means that an error has occured and we can’t run the following code. Otherwise, it signals to the code that we have a valid level and we can execute the code.

Now, we can get back to the rest of the GameLoop.

Further down, the array GameEnv.levels gets called in GameControl.js inside the Game Loop. We reference the level by its current index, and then check what transitions to do based on the completion.

We can actually see the isComplete property defined in the constructor for the level, so that we can run the callback function in scenarios like this.

Inspecting the transitionToLevel code reveals different action items on what we do to transition to a new level.

In GameControl.js

async transitionToLevel(newLevel) {
        this.inTransition = true;

        // Destroy existing game objects
        GameEnv.destroy();

        // Load GameLevel objects
        if (GameEnv.currentLevel !== newLevel) {
            GameEnv.claimedCoinIds = [];
        }
        await newLevel.load();
        GameEnv.currentLevel = newLevel;

        // Update invert property
        GameEnv.setInvert();
        
        // Trigger a resize to redraw canvas elements
        window.dispatchEvent(new Event('resize'));

        this.inTransition = false;
    },

First, we set the inTransition property to true. This is used to prevent the game from updating while we are transitioning which we already saw. Next, we destroy the current game objects using the GameEnv.destroy() function.

static destroy() {
        // Destroy objects in reverse order
        for (var i = GameEnv.gameObjects.length - 1; i >= 0; i--) {
            const gameObject = GameEnv.gameObjects[i];
            gameObject.destroy();
        }
        GameEnv.gameObjects = [];
    }

This code iterates through every game object and destroys it, and then makes the GameObjects array blank.

We see that when we destroy each object, we call another function defined in GameObject.js.

In GameObject.js

destroy() {
        const index = GameEnv.gameObjects.indexOf(this);
        if (index !== -1) {
            // Remove the canvas from the DOM
            this.canvas.parentNode.removeChild(this.canvas);
            GameEnv.gameObjects.splice(index, 1);
        }
    }

Here, we see that we find the index of each element, and remove it from the DOM by finding its parent and removing it from the canvas.

We then load the new GameLevel by running the newlevel.load() function. Again, since newlevel is a level, it has the load method defined in GameLevel.js

In GameLevel.js

async load() {
        Socket.removeAllListeners("stateUpdate") //reset Socket Connections
        Socket.removeAllListeners("disconnection")
        Socket.removeAllListeners("leaderboardUpdate")
        // Socket.createListener("leaderboardUpdate",this.handleLeaderboardUpdates)
        // Socket.createListener("stateUpdate",this.handleStateUpdates)
        Socket.createListener("disconnection",this.handleSocketDisconnect)
        try {
            var objFile = null;
            for (const obj of this.gameObjects) {
                if (obj.data.file) {
                    // Load the image for the game object.
                    objFile = obj.data.file; 
                    console.log(objFile);
                    obj.image = await this.loadImage(obj.data.file);
                    // Create a new canvas for the game object.
                    const canvas = document.createElement("canvas");
                    canvas.id = obj.id;
                    document.querySelector("#canvasContainer").appendChild(canvas);
                    // Create a new instance of the game object.
                    new obj.class(canvas, obj.image, obj.data, obj.xPercentage, obj.yPercentage, obj.name, obj.minPosition);
                }
            }
        } catch (error) {
            console.error('Failed to load one or more GameLevel objects: ' + objFile, error);
        }
    }

We see that in this code, we are iterating through each object in gameObjects and appending it to the canvas, essentially loading our level for the user to see.

After that, we invert the color of the screen. This is done using the GameEnv.setInvert() function.

We then resize the screen and set this.inTransition to false, signalling in other checks (like above) that we are done moving between levels and the level is ready to play.

Thus, we have a working and functioning platform to transform the GameObjects defined in a JavaScript Object through assets into a playable game that transitions in between levels, based on whatever the user does. We can see how everything intertwines and works together to create the whole Mario game.

DrawIO Diagram