10 min read

Fun Project #1: Super Mario Game With Kaboom.js

Fun Project #1: Super Mario Game With Kaboom.js
Photo by Boukaih / Unsplash

I feel really excited about this post. It's always thrilling to build a fun game knowing you are not a game developer. No matter how basic or sucky it is 😀 So this weekend, I worked on a poor man's version of Super Mario with Kaboom.js and wanted to share the project with you.

DISCLAIMER: This isn't really a how-to article or tutorial. The idea is to explain my thought process and what the code over here means with the hope that you can grasp the idea and concept behind the making of this. Maybe share a little bit about a few terms I have learned in the game dev world.

So before we move on, this is the pen for the project. If you want to actually play the game or play with the code on your own.

Super Mario

Getting started

Before we even write any code, let's quickly walk through the requirements for this project.

🔅
Before you write the code for any project, both personal or professional, you need to spend some time to at least, define some bare minimum requirements of what you are building. Trust me, it will save you a ton of headaches down the road.

So our simple requirements for our poor man's Mario game is as follows:

  1. The game should have two levels: Level 1 and Level 2
  2. Mario should be able to jump
  3. Mario should be able to open some boxes to pop up a coin or a mushroom
  4. Taking a coin increases the score by 1. Taking a mushroom should make Mario bigger and jump higher
  5. There should be some evil mushrooms. Colliding into an evil mushroom means game over. Jumping on them makes you destroy them.
  6. Falling in a hold means the game is over as well
  7. Entering a pipe takes you to another level

That wasn't so bad, was it? At this stage, you already have an idea of what we are trying to achieve and this can serve as a guide. We can now jump into the code.

Initialization

So there was no need for any CSS for this project. All that is needed is your index.html file and index.js file. Once you have those two files, next is to include your Kaboom.js dependency and initialize it like so:

<!--index.html-->

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!--remember to add this tag-->
  <meta name="referrer" content="no-referrer">
  <title>Super Mario</title>
  <!-- Add styles to body to remove margins and prevent overflow scrolling -->
  <style tyle="text/css">
    body {
      margin: 0;
      overflow: hidden;
    }
  </style>
</head>

<body>

  <!--include kaboom.js dependency-->
  <script src="https://kaboomjs.com/lib/0.5.0/kaboom.js"></script>
  <script src="index.js"></script>
</body>

</html>
/*index.js*/

kaboom({
  global: true, // import all kaboom functions to global namespace
  fullscreen: true, //put game in fullscreen
  scale: 1, // pixel size
  debug: true, // so we can see when something goes wrong
  clearColor: [0, 0, 0, 1] // make the background color of our game black. Becomes blue if we make the third value 1
});

This is basically all for the boilerplate of our project. We won't be touching the index.html file again. All our work is going to be done on index.js.

Next, we need to initialize a couple of variables right beneath the Kaboom initialization. I'll explain what each does in the comments.

const MOVE_SPEED = 120; //This is how fast we want mario to move. You can adjust this to your preference
const JUMP_FORCE = 430; //We want mario to jump when the spacebar is spread and we need to set jump force needs to be big enough else mario wont be seen to jump. You can play around with the values here too.
const BIG_JUMP_FORCE = 530; //We want mario to jump higher when he groes big.
let CURRENT_JUMP_FORCE = JUMP_FORCE;
const ENEMY_SPEED = 20;
const FALL_DEATH = 400;  //We will need this boundary to determine how low mario has fallen below the Y axis to determine death.
let isJumping = true; //This is needed as a logical check to determine how the collision with the evil mushroom happened. More on this later.

Loading of sprites

Sprites are basically two-dimensional bitmaps that are integrated into a larger scene. We'll talk about the scenes shortly. In the meantime, all the components you see in the game are sprites. The Mario player, the coins, the evil shrooms, the bricks, etc are all individual sprites that we need to load from our external storage into the asset manager of the Kaboom framework.

loadRoot("https://i.imgur.com/"); // root path for images (on my personal imgur account)

//Load assets into asset manager
loadSprite("coin", "izFd6pk.png"); // load our coin sprite
loadSprite("evil-shroom", "XqwgcAG.png");
loadSprite("brick", "40U790I.png");
loadSprite("block", "Zy4Xp2Q.png");
loadSprite("mario", "b5Ys2QV.png");
loadSprite("mushroom", "xZrfsd9.png");
loadSprite("surprise", "GxN32Kd.png"); //surprise is the box with the question mark which pops up either a coin or a mushroom which is eaten by mario to grow big
loadSprite("unboxed", "BRYKMTD.png"); //after the surprise box has been popped, the unbox sprite is the next state
loadSprite("pipe-top-left", "DBgfBsP.png");
loadSprite("pipe-top-right", "0AyWRDz.png");
loadSprite("pipe-bottom-left", "hUPVSFs.png");
loadSprite("pipe-bottom-right", "yuQk28O.png");

//Level 2 sprites: these are basically the blue versions of some of our level 1 //sprites needed for our level 2 scene
loadSprite("blue-block", "9hxdDzT.png");
loadSprite("blue-brick", "HLkPUdF.png");
loadSprite("blue-steel", "oUTPDqV.png");
loadSprite("blue-evil-shroom", "cFcJ8K4.png");
loadSprite("blue-surprise", "GxN32Kd.png");

Creation of scene and level maps

The scene is where all the magic happens. In the scene, we create our level map and config all the various functionalities of the game (keybindings, collisions, jumping, etc). We will have two scenes:

  1. The actual game scene (which will be populated with two levels)
  2. The game over screen, which will display the final score after Mario's death

Let's review the code for this. This is the meatiest part of our code, but not to worry. I explain what each code snippet does in the comments. This code is placed right beneath the loading of sprites as we did previously.

/*
* We initialise our main scene with a level of 0 and score of 1.
* You can see this initialisation in the last line of  this code with the start method
*/
scene("game", ({ level, score }) => {
  
 /** Next is to initialise your scene's layers
   * Layers allow you to group rendering by context, as well as allow you to pre-render things.
   * This enables you, for example, in rendering parts of your game that don't change much in memory, like a background.
   */

  /*so we are initialising the components of our layer, background layer, objects layer (where all objects are)
 and the UI layer to hold components like the score board etc.  Then finally, we are saying "obj" should be the default layer.
 Don't worry. This will make sense pretty soon.
 */
  layers(["bg", "obj", "ui"], "obj");

  /** Next is to define our map. In Kaboom.js map is used to define how your sprites will show on your scene. It's made up of an array of strings. So for example: In my map, the equal to sign is my floor and will be replaced by the appropriate brick sprite. In other words, this helps you to "draw" your scene with normal string symbols. 
   If you have 5 levels, your map array will be made up of 5 items. You get    the flow.
   Over here, even though I got just two levels, my map is made up of three items simply because the first item is level 0 and my game starts from level 1. Take a moment to go through this array to see if you can picture how our scene looks like.
   */
    
  const map = [
    [],
    //level 1 scene
    [
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "                                           ",
      "     %   =*=%=                           ",
      "                                           ",
      "                                  -+       ",
      "                    ^    ^        ()       ",
      "======================================  ==="
    ],
    
     //level 2 scene
    [
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E                                             E",
      "E        @@@@@@@           x x                E",
      "E                        x x x                E",
      "E                      x x x x              -+E",
      "E                z z x x x x x              ()E",
      "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!  !!!!!!!!"
    ]
  ];

  /*
  * At this stage, we are going to create our levels configuration object
  This is basically us defining the sprite for each symbol and what it's physics should be.
  */  
  const levelCfg = {
  //This is to set a baseline width and height for all our sprite images
    width: 20,
    height: 20,
    "=": [sprite("block"), solid()], 
    // replace my equal sign with the block sprite
    // solid() makes the sprite a block object with collission abilitie
    $: [sprite("coin"), solid(), "coin"],
    "%": [sprite("surprise"), solid(), "coin-surprise", scale(0.5)], 
    "*": [sprite("surprise"), solid(), "mushroom-surprise", scale(0.5)],
      //coin-surprise and mushroom-surprise are tags we have assigned. We can use these tags to assigned special event handlers to these sprites. Example: When mario collides with a coin-surprise, pop-up a coin
    "}": [sprite("unboxed"), solid()],
    "(": [sprite("pipe-bottom-left"), solid(), scale(0.5), "pipe"],
    ")": [sprite("pipe-bottom-right"), solid(), scale(0.5), "pipe"],
    "+": [sprite("pipe-top-right"), solid(), scale(0.5), "pipe"],
    "-": [sprite("pipe-top-left"), solid(), scale(0.5), "pipe"],
    "^": [sprite("evil-shroom"), solid(), "dangerous", body()],
    "#": [sprite("mushroom"), solid(), "mushroom", body()], 
    // body() makes gravity affect the item

    //level 2 sprites
    "!": [sprite("blue-block"), solid(), scale(0.5)],
    E: [sprite("blue-brick"), solid(), scale(0.5)],
    z: [sprite("blue-evil-shroom"), solid(), scale(0.5), "dangerous"],
    "@": [sprite("blue-surprise"), solid(), scale(0.5), "coin-surprise"],
    x: [sprite("blue-steel"), solid(), scale(0.5)]
  }; 
 
});


start("game", { level: 1, score: 0 });

Initializing the first level, adding the initial score at the top left, and defining the big function which makes Mario increase in size and reduce to normal after a time duration

This code will be placed right after our level 2 sprites definitions.

 const gameLevel = addLevel(map[level], levelCfg); 
//remember our initial level was passed to the start function previously
//so we are basically doing cont gameLevel = addLevel(map[1], levelCfg)

 const scoreLabel = add([
    text("Score:" + score, 13),
    pos(30, 10),
    layer("ui"),
    {
      value: score
    }
  ]); //add to the ui layer

  add([text("Level " + parseInt(level), 13), pos(160, 10)]); //show the level

  /*
  We define a big function which returns an object with methods that handle
  the scaling and down scaling of mario whenever he collides with a mushroom
  We set the CURRENT_JUMP_FORCE to the BIG_JUMP_FORCE when mario is big
  */

  function big() {
    let timer = 0;
    let isBig = false;
    return {
      update() {
        if (isBig) {
          CURRENT_JUMP_FORCE = BIG_JUMP_FORCE;
          timer -= dt(); // dt() is a Kaboom function (delta time). delta time since last scene refresh
          if (timer <= 0) {
            this.smallify();
          }
        }
      },
      isBig() {
        return isBig;
      },
      smallify() {
        this.scale = vec2(1);
        CURRENT_JUMP_FORCE = JUMP_FORCE;
        timer = 0;
        isBig = false;
      },
      biggify(time) {
        this.scale = vec2(2);
        timer = time;
        isBig = true;
      }
    };
  }

The big function returns an object with four methods: update, isBig, smallify, and biggify.

The big function defines two local variables: timer, which is initially set to zero, and isBig, which is initially set to false.

The update method checks if isBig is true, and if so, it decreases timer by the amount of time that has passed since the last update. If timer is less than or equal to zero, it calls the smallify method.

The isBig method simply returns the value of isBig.

The smallify method sets the scale property of the object to vec2(1), resets timer to zero, and sets isBig to false.

The biggify method sets the scale property of the object to vec2(2), sets timer to the specified time argument, and sets isBig to true.

Overall, this function creates an object that can be used to toggle the size of Mario on and off, using the biggify and smallify methods, while also providing a way to check whether the element is currently "big" using the isBig method, and updating the size of the element based on the elapsed time using the update method.

Adding Mario, keybindings, and defining collisions

This part of the code is pretty straightforward I can assure you. Let's review it.

const player = add([
    sprite("mario"),
    solid(),
    pos(30, 0),
    body(),
    big() //passing the big method to the player instance
 ]);

  //making mushrooms move
  /**Grab all the mushrooms and move them across the x axis */
  action("mushroom", (m) => {
    m.move(60, 0);
  });

  //mushrooms and coins
  /**
   * 1. if he jumps and bumps on a coin-surprise object, then spawn a coin right above that particular object
   * 2. Then afterwards, destroy the object we headbumped with
   * 3. Lastly, spawn the unboxed sprite right in the destroyed obj's position
   * 4. Similarly, if its a mushroom surprise, then we spawn a mushroom object
   */
  player.on("headbump", (obj) => {
    if (obj.is("coin-surprise")) {
      gameLevel.spawn("$", obj.gridPos.sub(0, 1));
      destroy(obj);
      gameLevel.spawn("}", obj.gridPos.sub(0, 0));
    }
    if (obj.is("mushroom-surprise")) {
      gameLevel.spawn("#", obj.gridPos.sub(0, 1));
      destroy(obj);
      gameLevel.spawn("}", obj.gridPos.sub(0, 0));
    }
  });

  /**Growing when mario collides with mushroom */
  /**
   * 1. Destroy mushroom upon collision
   * 2. Biggify player for 6s
   */
  player.collides("mushroom", (m) => {
    destroy(m);
    player.biggify(6);
  });

  /**collision with coin */
  /**
   * Destroy coin
   * Increase score label by 1
   */
  player.collides("coin", (c) => {
    destroy(c);
    scoreLabel.value++;
    scoreLabel.text = "Score: " + scoreLabel.value;
  });

  action("dangerous", (d) => {
    d.move(-ENEMY_SPEED, 0);
  });

  /**player collision with evil shroom, dangerous */
  /**
   * 1. If player collides with evil shroom, go to lose scene and display the score
   * 2. If player collides with evil shroom, but the collision was in a jumping event, then we destroy the evil shroom instead
   */
  player.collides("dangerous", (d) => {
    isJumping ? destroy(d) : go("lose", { score: scoreLabel.value });
  });

  /**When player collides with pipe and presses keydown, take the player the game scene and load the next level map */
  player.collides("pipe", () => {
    keyPress("down", () => {
      go("game", {
        level: level == 2 ? 1 : level + 1, // since we got only two levels, we reset to level 1 anytime the player is done with level 2
        score: scoreLabel.value
      });
    });
  });

  //adding event listeners to our player
  keyDown("left", () => {
    player.move(-MOVE_SPEED, 0); //moving to the left at MOVE_SPEED defined earlier
  });
  keyDown("right", () => {
    player.move(MOVE_SPEED, 0); //moving to the left at MOVE_SPEED
  });

  player.action(() => {
    camPos(player.pos); //make sure camera is always set on the player
    if (player.grounded()) {
      isJumping = false;
    }

    if (player.pos.y >= FALL_DEATH) {
      go("lose", { score: scoreLabel.value }); // we define how the loss scene looks like in the next section
    }
  });

  keyDown("space", () => {
    if (player.grounded()) {
      // only jump when player is grounded
      isJumping = true;
      player.jump(CURRENT_JUMP_FORCE);
    }
  });

Remember some of our sprites have tags. Eg: coin-surprise. So we can bind events to these sprites using their tags. Which is what we have also done.

The game-over scene

Based on the code above, we know that the game is over when Mario's y position is greater than or equal to the FALL_DEATH constant variable or when Mario collides with an evil shroom when he is not jumping.

scene("lose", ({ score }) => {
  add([
    text("Game Over! Score: " + score, 32),
    origin("center"),
    pos(width() / 2, height() / 2)
  ]);
}); //the score prop is passed to the lose scene in the go function

Conclusion

Whooo. You made it to the end.

As much as this wasn't so much of a step-by-step tutorial, I hope I was able to at least give you a rough idea of how I developed this game and you got some questions answered.

Don't hesitate to reach out to me for any further clarifications 😀