18 min read

How to create a Flappy Bird clone using MelonJS

Web game frameworks such as MelonJS are becoming more popular every day. In this post I will show you how easy it is to create a Flappy Bird clone game using the MelonJS bag of tricks. I will assume that you have some experience with JavaScript and that you have visited the melonJS official page. All of the code shown in this post is available on this GitHub repository.

Step 1 – Organization

A MelonJS game can be divided into three basic objects:

  • Scene objects: Define all of the game scenes (Play, Menus, Game Over, High Score, and so on)
  • Game entities: Add all of the stuff that interacts on the game (Players, enemies, collectables, and so on)
  • Hud entities: All of the HUD objects to be inserted on the scenes (Life, Score, Pause buttons, and so on)

For our Flappy Bird game, first create a directory, flappybirdgame, on your machine. Then create the following structure:

flabbybirdgame
|
|--js
|--|--entities
|--|--screens
|--|--game.js
|--|--resources.js
|--data
|--|--img
|--|--bgm
|--|--sfx
|--lib
|--index.html

Just a quick explanation about the folders:

  • The js contains all of the game source. The entities folder will handle the HUD and the Game entities. In the screen folder, we will create all of the scene files. The game.js is the main game file. It will initialize all of the game resources, which is created in the resources.js file, the input, and the loading of the first scene.
  • The data folder is where all of the assets, sounds, and game themes are inserted. I divided the folders into img for images (backgrounds, player atlas, menus, and so on), bgm for background music files (we need to provide a .ogg and .mp3 file for each sound if we want full compatibility with all browsers) and sfx for sound effects.
  • In the lib folder we will add the current 1.0.2 version of MelonJS.
  • Lastly, an index.html file is used to build the canvas.

Step 2 – Implementation

First we will build the game.js file:

var game = {
  data: {
    score : 0,
    steps: 0,
    start: false,
    newHiScore: false,
    muted: false
  },

  "onload": function() {
    if (!me.video.init("screen", 900, 600, true, 'auto')) {
      alert("Your browser does not support HTML5 canvas.");
      return;
    }

    me.audio.init("mp3,ogg");

    me.loader.onload = this.loaded.bind(this);
    me.loader.preload(game.resources);
    me.state.change(me.state.LOADING);
  },

  "loaded": function() {
    me.state.set(me.state.MENU, new game.TitleScreen());
    me.state.set(me.state.PLAY, new game.PlayScreen());
    me.state.set(me.state.GAME_OVER, new game.GameOverScreen());

    me.input.bindKey(me.input.KEY.SPACE, "fly", true);
    me.input.bindKey(me.input.KEY.M, "mute", true);
    me.input.bindPointer(me.input.KEY.SPACE);

    me.pool.register("clumsy", BirdEntity);
    me.pool.register("pipe", PipeEntity, true);
    me.pool.register("hit", HitEntity, true);

    // in melonJS 1.0.0, viewport size is set to Infinity by default
    me.game.viewport.setBounds(0, 0, 900, 600);
    me.state.change(me.state.MENU);
  }
};

The game.js is divided into:

  • data object: This global object will handle all of the global variables that will be used on the game. For our game we will use score to record the player score, and steps to record how far the bird goes. The other variables are flags that we are using to control some game states.
  • onload method: This method preloads the resources and initializes the canvas screen and then calls the loaded method when it’s done.
  • loaded method: This method first creates and puts into the state stack the screens that we will use on the game. We will use the implementation for these screens later on. It enables all of the input keys to handle the game. For our game we will be using the space and left mouse keys to control the bird and the M key to mute sound. It also adds the game entities BirdEntity, PipeEntity and the HitEntity in the game poll. I will explain the entities later.

Then you need to create the resource.js file:

game.resources = [

     {name: "bg", type:"image", src: "data/img/bg.png"},
     {name: "clumsy", type:"image", src: "data/img/clumsy.png"},
     {name: "pipe", type:"image", src: "data/img/pipe.png"},
     {name: "logo", type:"image", src: "data/img/logo.png"},
     {name: "ground", type:"image", src: "data/img/ground.png"},
     {name: "gameover", type:"image", src: "data/img/gameover.png"},
     {name: "gameoverbg", type:"image", src: "data/img/gameoverbg.png"},
     {name: "hit", type:"image", src: "data/img/hit.png"},
     {name: "getready", type:"image", src: "data/img/getready.png"},
     {name: "new", type:"image", src: "data/img/new.png"},
     {name: "share", type:"image", src: "data/img/share.png"},
     {name: "tweet", type:"image", src: "data/img/tweet.png"},
     {name: "leader", type:"image", src: "data/img/leader.png"},


     {name: "theme", type: "audio", src: "data/bgm/"},
     {name: "hit", type: "audio", src: "data/sfx/"},
     {name: "lose", type: "audio", src: "data/sfx/"},
     {name: "wing", type: "audio", src: "data/sfx/"},
];

Now let’s create the game entities. First the HUD elements: create a HUD.js file in the entities folder. In this file you will create:

  • A score entity
  • A background layer entity
  • The share buttons entities (Facebook, Twitter, and so on)
game.HUD = game.HUD || {};

game.HUD.Container = me.ObjectContainer.extend({
  init: function() {
    // call the constructor
    this.parent();
    // persistent across level change
    this.isPersistent = true;
    // non collidable
    this.collidable = false;
    // make sure our object is always draw first
    this.z = Infinity;
    // give a name
    this.name = "HUD";
    // add our child score object at the top left corner
    this.addChild(new game.HUD.ScoreItem(5, 5));
  }
});

game.HUD.ScoreItem = me.Renderable.extend({
  init: function(x, y) {
    // call the parent constructor
    // (size does not matter here)
    this.parent(new me.Vector2d(x, y), 10, 10);
    // local copy of the global score
    this.stepsFont = new me.Font('gamefont', 80, '#000', 'center');
    // make sure we use screen coordinates
    this.floating = true;
  },

  update: function() {
    return true;
  },

  draw: function (context) {
    if (game.data.start && me.state.isCurrent(me.state.PLAY))
      this.stepsFont.draw(context, game.data.steps, me.video.getWidth()/2, 10);
  }

});

var BackgroundLayer = me.ImageLayer.extend({
  init: function(image, z, speed) {
    name = image;
    width = 900;
    height = 600;
    ratio = 1;
    // call parent constructor
    this.parent(name, width, height, image, z, ratio);
  },

  update: function() {
    if (me.input.isKeyPressed('mute')) {
      game.data.muted = !game.data.muted;
      if (game.data.muted){
        me.audio.disable();
      }else{
        me.audio.enable();
      }
    }
    return true;
  }
});

var Share = me.GUI_Object.extend({
  init: function(x, y) {
    var settings = {};
    settings.image = "share";
    settings.spritewidth = 150;
    settings.spriteheight = 75;
    this.parent(x, y, settings);
  },

  onClick: function(event) {
    var shareText = 'Just made ' + game.data.steps + ' steps on Clumsy Bird! Can you beat me? Try online here!';
    var url = 'http://ellisonleao.github.io/clumsy-bird/';
    FB.ui(
      {
       method: 'feed',
       name: 'My Clumsy Bird Score!',
       caption: "Share to your friends",
       description: (
          shareText
       ),
       link: url,
       picture: 'http://ellisonleao.github.io/clumsy-bird/data/img/clumsy.png'
      }
    );
    return false;
  }

});

var Tweet = me.GUI_Object.extend({
  init: function(x, y) {
    var settings = {};
    settings.image = "tweet";
    settings.spritewidth = 152;
    settings.spriteheight = 75;
    this.parent(x, y, settings);
  },

  onClick: function(event) {
    var shareText = 'Just made ' + game.data.steps + ' steps on Clumsy Bird! Can you beat me? Try online here!';
    var url = 'http://ellisonleao.github.io/clumsy-bird/';
    var hashtags = 'clumsybird,melonjs'
    window.open('https://twitter.com/intent/tweet?text=' + shareText + '&hashtags=' + hashtags + '&count=' + url + '&url=' + url, 'Tweet!', 'height=300,width=400')
    return false;
  }

});

You should notice that there are different me classes for different types of entities. The ScoreItem is a Renderable object that is created under an ObjectContainer and it will render the game steps on the play screen that we will create later. The share and Tweet buttons are created with the GUI_Object class. This class implements the onClick event that handles click events used to create the share events. The BackgroundLayer is a particular object created using the ImageLayer class. This class controls some generic image layers that can be used in the game. In our particular case we are just using a single fixed image, with fixed ratio and no scrolling.

Now to the game entities. For this game we will need:

  • BirdEntity: The bird and its behavior
  • PipeEntity: The pipe object
  • HitEntity: A invisible entity just to get the steps counting
  • PipeGenerator: Will handle the PipeEntity creation
  • Ground: A entity for the ground
  • TheGround: The animated ground Container

Add an entities.js file into the entities folder:

var BirdEntity = me.ObjectEntity.extend({
  init: function(x, y) {
    var settings = {};
    settings.image = me.loader.getImage('clumsy');
    settings.width = 85;
    settings.height = 60;
    settings.spritewidth = 85;
    settings.spriteheight= 60;

    this.parent(x, y, settings);
    this.alwaysUpdate = true;
    this.gravity = 0.2;
    this.gravityForce = 0.01;
    this.maxAngleRotation = Number.prototype.degToRad(30);
    this.maxAngleRotationDown = Number.prototype.degToRad(90);
    this.renderable.addAnimation("flying", [0, 1, 2]);
    this.renderable.addAnimation("idle", [0]);
    this.renderable.setCurrentAnimation("flying");
    this.animationController = 0;
    // manually add a rectangular collision shape
    this.addShape(new me.Rect(new me.Vector2d(5, 5), 70, 50));

    // a tween object for the flying physic effect
    this.flyTween = new me.Tween(this.pos);
    this.flyTween.easing(me.Tween.Easing.Exponential.InOut);
  },

  update: function(dt) {
    // mechanics
    if (game.data.start) {
      if (me.input.isKeyPressed('fly')) {
        me.audio.play('wing');
        this.gravityForce = 0.01;

        var currentPos = this.pos.y;
        // stop the previous one
        this.flyTween.stop()
        this.flyTween.to({y: currentPos - 72}, 100);
        this.flyTween.start();

        this.renderable.angle = -this.maxAngleRotation;
      } else {
        this.gravityForce += 0.2;
        this.pos.y += me.timer.tick * this.gravityForce;
        this.renderable.angle += Number.prototype.degToRad(3) * me.timer.tick;
        if (this.renderable.angle > this.maxAngleRotationDown)
          this.renderable.angle = this.maxAngleRotationDown;
      }
    }

    var res = me.game.world.collide(this);

    if (res) {
      if (res.obj.type != 'hit') {
        me.device.vibrate(500);
        me.state.change(me.state.GAME_OVER);
        return false;
      }
      // remove the hit box
      me.game.world.removeChildNow(res.obj);
      // the give dt parameter to the update function
      // give the time in ms since last frame
      // use it instead ?
      game.data.steps++;
      me.audio.play('hit');

    } else {
      var hitGround = me.game.viewport.height - (96 + 60);
      var hitSky = -80; // bird height + 20px
      if (this.pos.y >= hitGround || this.pos.y <= hitSky) {
        me.state.change(me.state.GAME_OVER);
        return false;
      }
    }

    return this.parent(dt);

  },

});

var PipeEntity = me.ObjectEntity.extend({
  init: function(x, y) {
    var settings = {};
    settings.image = me.loader.getImage('pipe');
    settings.width = 148;
    settings.height= 1664;
    settings.spritewidth = 148;
    settings.spriteheight= 1664;


    this.parent(x, y, settings);
    this.alwaysUpdate = true;
    this.gravity = 5;
    this.updateTime = false;
  },

  update: function(dt) {
    // mechanics
    this.pos.add(new me.Vector2d(-this.gravity * me.timer.tick, 0));
    if (this.pos.x < -148) {
      me.game.world.removeChild(this);
    }
    return true;
  },

});

var PipeGenerator = me.Renderable.extend({
  init: function() {
    this.parent(new me.Vector2d(), me.game.viewport.width, me.game.viewport.height);
    this.alwaysUpdate = true;
    this.generate = 0;
    this.pipeFrequency = 92;
    this.pipeHoleSize = 1240;
    this.posX = me.game.viewport.width;
  },

  update: function(dt) {
    if (this.generate++ % this.pipeFrequency == 0) {
      var posY = Number.prototype.random(
          me.video.getHeight() - 100,
          200
      );
      var posY2 = posY - me.video.getHeight() - this.pipeHoleSize;
      var pipe1 = new me.pool.pull("pipe", this.posX, posY);
      var pipe2 = new me.pool.pull("pipe", this.posX, posY2);
      var hitPos = posY - 100;
      var hit = new me.pool.pull("hit", this.posX, hitPos);
      pipe1.renderable.flipY();
      me.game.world.addChild(pipe1, 10);
      me.game.world.addChild(pipe2, 10);
      me.game.world.addChild(hit, 11);
    }
    return true;
  },

});

var HitEntity = me.ObjectEntity.extend({
  init: function(x, y) {
    var settings = {};
    settings.image = me.loader.getImage('hit');
    settings.width = 148;
    settings.height= 60;
    settings.spritewidth = 148;
    settings.spriteheight= 60;

    this.parent(x, y, settings);
    this.alwaysUpdate = true;
    this.gravity = 5;
    this.updateTime = false;
    this.type = 'hit';
    this.renderable.alpha = 0;
    this.ac = new me.Vector2d(-this.gravity, 0);
  },

  update: function() {
    // mechanics
    this.pos.add(this.ac);
    if (this.pos.x < -148) {
      me.game.world.removeChild(this);
    }
    return true;
  },

});

var Ground = me.ObjectEntity.extend({
  init: function(x, y) {
    var settings = {};
    settings.image = me.loader.getImage('ground');
    settings.width = 900;
    settings.height= 96;

    this.parent(x, y, settings);
    this.alwaysUpdate = true;
    this.gravity = 0;
    this.updateTime = false;
    this.accel = new me.Vector2d(-4, 0);
  },

  update: function() {
    // mechanics
    this.pos.add(this.accel);
    if (this.pos.x < -this.renderable.width) {
      this.pos.x = me.video.getWidth() - 10;
    }
    return true;
  },

});

var TheGround = Object.extend({
  init: function() {
    this.ground1 = new Ground(0, me.video.getHeight() - 96);
    this.ground2 = new Ground(me.video.getWidth(), me.video.getHeight() - 96);
    me.game.world.addChild(this.ground1, 11);
    me.game.world.addChild(this.ground2, 11);
  },

  update: function () { return true; }
})

Note that every game entity inherits from the me.ObjectEntity class. We need to pass the settings of the entity on the init method, telling it which image we will use from the resources along with the image measure. We also implement the update method for each Entity, telling it how it will behave during game time.

Now we need to create our scenes. The game is divided into:

  • TitleScreen
  • PlayScreen
  • GameOverScreen

We will separate the scenes into js files. First create a title.js file in the screens folder:

game.TitleScreen = me.ScreenObject.extend({
  init: function(){
    this.font = null;
  },

  onResetEvent: function() {
    me.audio.stop("theme");
    game.data.newHiScore = false;
    me.game.world.addChild(new BackgroundLayer('bg', 1));

    me.input.bindKey(me.input.KEY.ENTER, "enter", true);
    me.input.bindKey(me.input.KEY.SPACE, "enter", true);
    me.input.bindPointer(me.input.mouse.LEFT, me.input.KEY.ENTER);

    this.handler = me.event.subscribe(me.event.KEYDOWN, function (action, keyCode, edge) {
      if (action === "enter") {
        me.state.change(me.state.PLAY);
      }
    });

    //logo
    var logoImg = me.loader.getImage('logo');
    var logo = new me.SpriteObject (
      me.game.viewport.width/2 - 170,
      -logoImg,
      logoImg
    );
    me.game.world.addChild(logo, 10);

    var logoTween = new me.Tween(logo.pos).to({y: me.game.viewport.height/2 - 100},
        1000).easing(me.Tween.Easing.Exponential.InOut).start();

    this.ground = new TheGround();
    me.game.world.addChild(this.ground, 11);

    me.game.world.addChild(new (me.Renderable.extend ({
        // constructor
        init: function() {
            // size does not matter, it's just to avoid having a zero size
            // renderable
            this.parent(new me.Vector2d(), 100, 100);
            //this.font = new me.Font('Arial Black', 20, 'black', 'left');
            this.text = me.device.touch ? 'Tap to start' : 'PRESS SPACE OR CLICK LEFT MOUSE BUTTON TO START ntttttttttttPRESS "M" TO MUTE SOUND';
            this.font = new me.Font('gamefont', 20, '#000');
        },
        update: function () {
            return true;
        },
        draw: function (context) {
            var measure = this.font.measureText(context, this.text);
            this.font.draw(context, this.text, me.game.viewport.width/2 - measure.width/2, me.game.viewport.height/2 + 50);
        }
    })), 12);
  },

  onDestroyEvent: function() {
      // unregister the event
    me.event.unsubscribe(this.handler);
    me.input.unbindKey(me.input.KEY.ENTER);
        me.input.unbindKey(me.input.KEY.SPACE);
    me.input.unbindPointer(me.input.mouse.LEFT);
    me.game.world.removeChild(this.ground);
  }
});

Then, create a play.js file on the same folder:

game.PlayScreen = me.ScreenObject.extend({
  init: function() {
    me.audio.play("theme", true);
    // lower audio volume on firefox browser
    var vol = me.device.ua.contains("Firefox") ? 0.3 : 0.5;
    me.audio.setVolume(vol);
    this.parent(this);
  },

  onResetEvent: function() {
    me.audio.stop("theme");
    if (!game.data.muted){
      me.audio.play("theme", true);
    }

    me.input.bindKey(me.input.KEY.SPACE, "fly", true);
    game.data.score = 0;
    game.data.steps = 0;
    game.data.start = false;
    game.data.newHiscore = false;

    me.game.world.addChild(new BackgroundLayer('bg', 1));

    this.ground = new TheGround();
    me.game.world.addChild(this.ground, 11);

    this.HUD = new game.HUD.Container();
    me.game.world.addChild(this.HUD);

    this.bird = me.pool.pull("clumsy", 60, me.game.viewport.height/2 - 100);
    me.game.world.addChild(this.bird, 10);

    //inputs
    me.input.bindPointer(me.input.mouse.LEFT, me.input.KEY.SPACE);

    this.getReady = new me.SpriteObject(
      me.video.getWidth()/2 - 200,
      me.video.getHeight()/2 - 100,
      me.loader.getImage('getready')
    );
    me.game.world.addChild(this.getReady, 11);

    var fadeOut = new me.Tween(this.getReady).to({alpha: 0}, 2000)
      .easing(me.Tween.Easing.Linear.None)
      .onComplete(function() {
            game.data.start = true;
            me.game.world.addChild(new PipeGenerator(), 0);
       }).start();
  },

  onDestroyEvent: function() {
    me.audio.stopTrack('theme');
    // free the stored instance
    this.HUD = null;
    this.bird = null;
    me.input.unbindKey(me.input.KEY.SPACE);
    me.input.unbindPointer(me.input.mouse.LEFT);
  }
});

Finally, the gameover.js screen:

game.GameOverScreen = me.ScreenObject.extend({

  init: function() {
    this.savedData = null;
    this.handler = null;
  },

  onResetEvent: function() {
    me.audio.play("lose");
    //save section
    this.savedData = {
      score: game.data.score,
      steps: game.data.steps
    };
    me.save.add(this.savedData);

    // clay.io
    if (game.data.score > 0) {
      me.plugin.clay.leaderboard('clumsy');
    }

    if (!me.save.topSteps) me.save.add({topSteps: game.data.steps});
    if (game.data.steps > me.save.topSteps) {
      me.save.topSteps = game.data.steps;
      game.data.newHiScore = true;
    }
    me.input.bindKey(me.input.KEY.ENTER, "enter", true);
    me.input.bindKey(me.input.KEY.SPACE, "enter", false)
    me.input.bindPointer(me.input.mouse.LEFT, me.input.KEY.ENTER);

    this.handler = me.event.subscribe(me.event.KEYDOWN, function (action, keyCode, edge) {
        if (action === "enter") {
            me.state.change(me.state.MENU);
        }
    });

    var gImage =  me.loader.getImage('gameover');
    me.game.world.addChild(new me.SpriteObject(
        me.video.getWidth()/2 - gImage.width/2,
        me.video.getHeight()/2 - gImage.height/2 - 100,
        gImage
    ), 12);

    var gImageBoard = me.loader.getImage('gameoverbg');
    me.game.world.addChild(new me.SpriteObject(
      me.video.getWidth()/2 - gImageBoard.width/2,
      me.video.getHeight()/2 - gImageBoard.height/2,
      gImageBoard
    ), 10);

    me.game.world.addChild(new BackgroundLayer('bg', 1));
    this.ground = new TheGround();
    me.game.world.addChild(this.ground, 11);

    // share button
    var buttonsHeight = me.video.getHeight() / 2 + 200;
    this.share = new Share(me.video.getWidth()/3 - 100, buttonsHeight);
    me.game.world.addChild(this.share, 12);

    //tweet button
    this.tweet = new Tweet(this.share.pos.x + 170, buttonsHeight);
    me.game.world.addChild(this.tweet, 12);

    //leaderboard button
    this.leader = new Leader(this.tweet.pos.x + 170, buttonsHeight);
    me.game.world.addChild(this.leader, 12);

    // add the dialog witht he game information
    if (game.data.newHiScore) {
      var newRect = new me.SpriteObject(
          235,
          355,
          me.loader.getImage('new')
      );
      me.game.world.addChild(newRect, 12);
    }

    this.dialog = new (me.Renderable.extend({
      // constructor
      init: function() {
          // size does not matter, it's just to avoid having a zero size
          // renderable
          this.parent(new me.Vector2d(), 100, 100);
          this.font = new me.Font('gamefont', 40, 'black', 'left');
          this.steps = 'Steps: ' + game.data.steps.toString();
          this.topSteps= 'Higher Step: ' + me.save.topSteps.toString();
      },

      update: function () {
        return true;
      },

      draw: function (context) {
        var stepsText = this.font.measureText(context, this.steps);
        var topStepsText = this.font.measureText(context, this.topSteps);

        var scoreText = this.font.measureText(context, this.score);
        //steps
        this.font.draw(
            context,
            this.steps,
            me.game.viewport.width/2 - stepsText.width/2 - 60,
            me.game.viewport.height/2
        );
        //top score
        this.font.draw(
            context,
            this.topSteps,
            me.game.viewport.width/2 - stepsText.width/2 - 60,
            me.game.viewport.height/2 + 50
        );

      }
    }));
    me.game.world.addChild(this.dialog, 12);
  },

  onDestroyEvent: function() {
    // unregister the event
    me.event.unsubscribe(this.handler);
    me.input.unbindKey(me.input.KEY.ENTER);
    me.input.unbindKey(me.input.KEY.SPACE);
    me.input.unbindPointer(me.input.mouse.LEFT);
    me.game.world.removeChild(this.ground);
    this.font = null;
    me.audio.stop("theme");
  }
}); 

Here is how the ScreenObjects works:

  • First it calls the init constructor method for any variable initialization.
  • onResetEvent is called next. This method will be called every time the scene is called. In our case the onResetEvent will add some objects to the game world stack.
  • The onDestroyEvent acts like a garbage collector and unregisters bind events and removes some elements on the draw calls.

Now, let’s put it all together in the index.html file:

<!DOCTYPE HTML>
<html lang="en">
  <head>
    <title>Clumsy Bird</title>
  </head>

  <body>
    <!-- the facebook init for the share button -->     
    <div id="fb-root"></div>
    <script>
      window.fbAsyncInit = function() {
        FB.init({
          appId      : '213642148840283',
          status     : true,
          xfbml      : true
        });
      };

      (function(d, s, id){
         var js, fjs = d.getElementsByTagName(s)[0];
         if (d.getElementById(id)) {return;}
         js = d.createElement(s); js.id = id;
         js.src = "//connect.facebook.net/pt_BR/all.js";
         fjs.parentNode.insertBefore(js, fjs);
       }(document, 'script', 'facebook-jssdk'));
    </script>
    <!-- Canvas placeholder -->
    <div id="screen"></div>

    <!-- melonJS Library -->
    <script type="text/javascript" src="lib/melonJS-1.0.2.js" ></script>
    <script type="text/javascript" src="js/entities/HUD.js" ></script>
    <script type="text/javascript" src="js/entities/entities.js" ></script>
    <script type="text/javascript" src="js/screens/title.js" ></script>
    <script type="text/javascript" src="js/screens/play.js" ></script>
    <script type="text/javascript" src="js/screens/gameover.js" ></script>
  </body>
</html>

Step 3 – Flying!

To run our game we will need a web server of your choice. If you have Python installed, you can simply type the following in your shell:

$python -m SimpleHTTPServer

Then you can open your browser at http://localhost:8000.

If all went well, you will see the title screen after it loads, like in the following image:

Flappy Bird with Melon.JS

I hope you enjoyed this post! 

About this author

Ellison Leão (@ellisonleao) is a passionate software engineer with more than 6 years of experience in web projects and is a contributor to the MelonJS framework and other open source projects. When he is not writing games, he loves to play drums.

2 COMMENTS

LEAVE A REPLY

Please enter your comment!
Please enter your name here