Phaser.js tutorial: Building a polished space shooter game (Part 5)

tutorial boss level

You’ve made it to the boss level of my javascript game development tutorial series with phaser.js. In the last part we did a lot of work on the pacing of our game. To top it off, we’ll add a big bad boss.

In this final part, we add a lot of code to create a powerful boss with his own movement behaviour, weapons and firing mechanism, and a simple yet effective AI to make him respond to the player’s movements. It’s a lot of code and a fair amount of refactoring, with some new material, but most of it is techniques we’ve used before. You’re final challenge is to work through the boss code on your own.

If you haven’t gone through the rest of this tutorial already, do that that now:

You can play the finished game here. All of the code and assets are available on github. In the code below, new code is in green, and removed code is in red.

Disclaimer: the whole code could be much better organised if this were a production game. I have laid it out progressively with each step of the tutorial to make it more linear to follow. In a “real” project I would have organised it much differently.

Step 27: Add big bad boss

The game is really feeling good now. To finish it off, let’s add a boss. The boss is going to be huge and powerful and exciting and hard to kill.

Like the boss in the game, this final step of the tutorial is the most challenging. It is long and complex, though most of it is built up with similar techniques we have already used. A lot of attention has been given to the pacing and the boss’s AI.

A few points of interest:

  • Note how the launch frequency of the other enemies get significantly reduced when the boss appears. This is to create a dramatic pause for his entrance.

  • We use a group for the boss to keep his ship and death rays aligned and in the right z-index order.

  • The movement of the boss feels very alive and responsive to the player, but it is controlled simply by making him bob up and down near the top of the screen, and adjust left and right based on the player’s x-position (similar to how the mouse input controls the player’s movement).

  • The AI for firing the death rays is accomplished by comparing angles to tell when the player’s ship is “in target.” At that point, we set off cascading timers create the warning “charge-up” period right before firing the rays. The visual effects of the rays came from playing with some extreme scaling of the bullet assets, and adjusting until it felt right.

  • Notice how the boss’s plasma trail comes from two exhaust ports instead of one. Instead of creating two particle emitters, I randomly alternate an offset to the left and right to make it look like two sources. Again, I’m reusing a bullet asset to good effect.

  • Since the boss is so big, when we finally do kill him, he gets a double round of big explosions to match the size of the accomplishment. In order to have time for this nice effect, I’ve adjusted how and when an enemy sprite gets killed, creating a notion of a “terminal damage threshold.”

  • Finally, we can add a custom hit detection function to fine-tune a collision. In this case, the collision must happen in the regions of the boss that his triangular ship fills.

Play the game at this step
View the code at this step


...
 var greenEnemySpacing = 1000;
 var blueEnemyLaunchTimer;
 var blueEnemyLaunched = false;
+var blueEnemySpacing = 2500;
+var bossLaunchTimer;
+var bossLaunched = false;
+var bossSpacing = 20000;
+var bossBulletTimer = 0;
+var bossYdirection = -1;
 var gameOver;
 
 var ACCLERATION = 600;
...
     game.load.image('blueEnemyBullet', '/assets/enemy-blue-bullet.png');
     game.load.spritesheet('explosion', '/assets/explode.png', 128, 128);
     game.load.bitmapFont('spacefont', '/assets/spacefont/spacefont.png', '/assets/spacefont/spacefont.xml');  
+    game.load.image('boss', '/assets/boss.png');
+    game.load.image('deathRay', '/assets/death-ray.png');
 }
 
 function create() {
...
         enemy.damageAmount = 40;
     });
 
+    //  The boss
+    boss = game.add.sprite(0, 0, 'boss');
+    boss.exists = false;
+    boss.alive = false;
+    boss.anchor.setTo(0.5, 0.5);
+    boss.damageAmount = 50;
+    boss.angle = 180;
+    boss.scale.x = 0.6;
+    boss.scale.y = 0.6;
+    game.physics.enable(boss, Phaser.Physics.ARCADE);
+    boss.body.maxVelocity.setTo(100, 80);
+    boss.dying = false;
+    boss.finishOff = function() {
+        if (!boss.dying) {
+            boss.dying = true;
+            bossDeath.x = boss.x;
+            bossDeath.y = boss.y;
+            bossDeath.start(false, 1000, 50, 20);
+            //  kill boss after explotions
+            game.time.events.add(1000, function(){
+                var explosion = explosions.getFirstExists(false);
+                var beforeScaleX = explosions.scale.x;
+                var beforeScaleY = explosions.scale.y;
+                var beforeAlpha = explosions.alpha;
+                explosion.reset(boss.body.x + boss.body.halfWidth, boss.body.y + boss.body.halfHeight);
+                explosion.alpha = 0.4;
+                explosion.scale.x = 3;
+                explosion.scale.y = 3;
+                var animation = explosion.play('explosion', 30, false, true);
+                animation.onComplete.addOnce(function(){
+                    explosion.scale.x = beforeScaleX;
+                    explosion.scale.y = beforeScaleY;
+                    explosion.alpha = beforeAlpha;
+                });
+                boss.kill();
+                booster.kill();
+                boss.dying = false;
+                bossDeath.on = false;
+                //  queue next boss
+                bossLaunchTimer = game.time.events.add(game.rnd.integerInRange(bossSpacing, bossSpacing + 5000), launchBoss);
+            });
+
+            //  reset pacing for other enemies
+            blueEnemySpacing = 2500;
+            greenEnemySpacing = 1000;
+
+            //  give some bonus health
+            player.health = Math.min(100, player.health + 40);
+            shields.render();
+        }
+    };
+
+    //  Boss death ray
+    function addRay(leftRight) {
+        var ray = game.add.sprite(leftRight * boss.width * 0.75, 0, 'deathRay');
+        ray.alive = false;
+        ray.visible = false;
+        boss.addChild(ray);
+        ray.crop({x: 0, y: 0, width: 40, height: 40});
+        ray.anchor.x = 0.5;
+        ray.anchor.y = 0.5;
+        ray.scale.x = 2.5;
+        ray.damageAmount = boss.damageAmount;
+        game.physics.enable(ray, Phaser.Physics.ARCADE);
+        ray.body.setSize(ray.width / 5, ray.height / 4);
+        ray.update = function() {
+            this.alpha = game.rnd.realInRange(0.6, 1);
+        };
+        boss['ray' + (leftRight > 0 ? 'Right' : 'Left')] = ray;
+    }
+    addRay(1);
+    addRay(-1);
+    //  need to add the ship texture to the group so it renders over the rays
+    var ship = game.add.sprite(0, 0, 'boss');
+    ship.anchor = {x: 0.5, y: 0.5};
+    boss.addChild(ship);
+
+    boss.fire = function() {
+        if (game.time.now > bossBulletTimer) {
+            var raySpacing = 3000;
+            var chargeTime = 1500;
+            var rayTime = 1500;
+
+            function chargeAndShoot(side) {
+                ray = boss['ray' + side];
+                ray.name = side
+                ray.revive();
+                ray.y = 80;
+                ray.alpha = 0;
+                ray.scale.y = 13;
+                game.add.tween(ray).to({alpha: 1}, chargeTime, Phaser.Easing.Linear.In, true).onComplete.add(function(ray){
+                    ray.scale.y = 150;
+                    game.add.tween(ray).to({y: -1500}, rayTime, Phaser.Easing.Linear.In, true).onComplete.add(function(ray){
+                        ray.kill();
+                    });
+                });
+            }
+            chargeAndShoot('Right');
+            chargeAndShoot('Left');
+
+            bossBulletTimer = game.time.now + raySpacing;
+        }
+    };
+
+    boss.update = function() {
+      if (!boss.alive) return;
+
+      boss.rayLeft.update();
+      boss.rayRight.update();
+
+      if (boss.y > 140) {
+        boss.body.acceleration.y = -50;
+      }
+      if (boss.y < 140) {
+        boss.body.acceleration.y = 50;
+      }
+      if (boss.x > player.x + 50) {
+        boss.body.acceleration.x = -50;
+      } else if (boss.x < player.x - 50) {
+        boss.body.acceleration.x = 50;
+      } else {
+        boss.body.acceleration.x = 0;
+      }
+
+      //  Squish and rotate boss for illusion of "banking"
+      var bank = boss.body.velocity.x / MAXSPEED;
+      boss.scale.x = 0.6 - Math.abs(bank) / 3;
+      boss.angle = 180 - bank * 20;
+
+      booster.x = boss.x + -5 * bank;
+      booster.y = boss.y + 10 * Math.abs(bank) - boss.height / 2;
+
+      //  fire if player is in target
+      var angleToPlayer = game.math.radToDeg(game.physics.arcade.angleBetween(boss, player)) - 90;
+      var anglePointing = 180 - Math.abs(boss.angle);
+      if (anglePointing - angleToPlayer < 18) {
+          boss.fire();
+      }
+    }
+
+    //  boss's boosters
+    booster = game.add.emitter(boss.body.x, boss.body.y - boss.height / 2);
+    booster.width = 0;
+    booster.makeParticles('blueEnemyBullet');
+    booster.forEach(function(p){
+      p.crop({x: 120, y: 0, width: 45, height: 50});
+      //  clever way of making 2 exhaust trails by shifing particles randomly left or right
+      p.anchor.x = game.rnd.pick([1,-1]) * 0.95 + 0.5;
+      p.anchor.y = 0.75;
+    });
+    booster.setXSpeed(0, 0);
+    booster.setRotation(0,0);
+    booster.setYSpeed(-30, -50);
+    booster.gravity = 0;
+    booster.setAlpha(1, 0.1, 400);
+    booster.setScale(0.3, 0, 0.7, 0, 5000, Phaser.Easing.Quadratic.Out);
+    boss.bringToTop();
+
     //  And some controls to play the game with
     cursors = game.input.keyboard.createCursorKeys();
     fireButton = game.input.keyboard.addKey(Phaser.Keyboard.SPACEBAR);
...
     playerDeath.setAlpha(0.9, 0, 800);
     playerDeath.setScale(0.1, 0.6, 0.1, 0.6, 1000, Phaser.Easing.Quintic.Out);
 
+    //  Big explosion for boss
+    bossDeath = game.add.emitter(boss.x, boss.y);
+    bossDeath.width = boss.width / 2;
+    bossDeath.height = boss.height / 2;
+    bossDeath.makeParticles('explosion', [0,1,2,3,4,5,6,7], 20);
+    bossDeath.setAlpha(0.9, 0, 900);
+    bossDeath.setScale(0.3, 1.0, 0.3, 1.0, 1000, Phaser.Easing.Quintic.Out);
+
     //  Shields stat
     shields = game.add.bitmapText(game.world.width - 250, 10, 'spacefont', '' + player.health +'%', 50);
     shields.render = function () {
...
     game.physics.arcade.overlap(greenEnemies, bullets, hitEnemy, null, this);
 
     game.physics.arcade.overlap(player, blueEnemies, shipCollide, null, this);
-    game.physics.arcade.overlap(bullets, blueEnemies, hitEnemy, null, this);
+    game.physics.arcade.overlap(blueEnemies, bullets, hitEnemy, null, this);
+
+    game.physics.arcade.overlap(boss, bullets, hitEnemy, bossHitTest, this);
+    game.physics.arcade.overlap(player, boss.rayLeft, enemyHitsPlayer, null, this);
+    game.physics.arcade.overlap(player, boss.rayRight, enemyHitsPlayer, null, this);
 
     game.physics.arcade.overlap(blueEnemyBullets, player, enemyHitsPlayer, null, this);
 
...
     var frequency = 70;
     var verticalSpacing = 70;
     var numEnemiesInWave = 5;
-    var timeBetweenWaves = 2500;
 
     //  Launch wave
     for (var i =0; i < numEnemiesInWave; i++) {
...
     }
 
     //  Send another wave soon
-    blueEnemyLaunchTimer = game.time.events.add(game.rnd.integerInRange(timeBetweenWaves, timeBetweenWaves + 4000), launchBlueEnemy);
+    blueEnemyLaunchTimer = game.time.events.add(game.rnd.integerInRange(blueEnemySpacing, blueEnemySpacing + 4000), launchBlueEnemy);
+}
+
+function launchBoss() {
+    boss.reset(game.width / 2, -boss.height);
+    booster.start(false, 1000, 10);
+    boss.health = 501;
+    bossBulletTimer = game.time.now + 5000;
 }
 
 function addEnemyEmitterTrail(enemy) {
...
     explosion.body.velocity.y = enemy.body.velocity.y;
     explosion.alpha = 0.7;
     explosion.play('explosion', 30, false, true);
-    enemy.kill();
-    bullet.kill()
+    if (enemy.finishOff && enemy.health < 5) {
+      enemy.finishOff();
+    } else {
+        enemy.damage(enemy.damageAmount);
+    }
+    bullet.kill();
 
     // Increase score
     score += enemy.damageAmount * 10;
     scoreText.render();
 
     //  Pacing
+
     //  Enemies come quicker as score increases
     greenEnemySpacing *= 0.9;
+
     //  Blue enemies come in after a score of 1000
     if (!blueEnemyLaunched && score > 1000) {
       blueEnemyLaunched = true;
...
       //  Slow green enemies down now that there are other enemies
       greenEnemySpacing *= 2;
     }
+
+    //  Launch boss
+    if (!bossLaunched && score > 15000) {
+        greenEnemySpacing = 5000;
+        blueEnemySpacing = 12000;
+        //  dramatic pause before boss
+        game.time.events.add(2000, function(){
+          bossLaunched = true;
+          launchBoss();
+        });
+    }
+
     //  Weapon upgrade
-    if (score > 4000 && player.weaponLevel < 2) {
+    if (score > 5000 && player.weaponLevel < 2) {
       player.weaponLevel = 2;
     }
 }
 
+//  Don't count a hit in the lower right and left quarants to aproximate better collisions
+function bossHitTest(boss, bullet) {
+    if ((bullet.x > boss.x + boss.width / 5 &&
+        bullet.y > boss.y) ||
+        (bullet.x < boss.x - boss.width / 5 &&
+        bullet.y > boss.y)) {
+      return false;
+    } else {
+      return true;
+    }
+}
+
 function enemyHitsPlayer (player, bullet) {
     bullet.kill();
 
...
     blueEnemies.callAll('kill');
     blueEnemyBullets.callAll('kill');
     game.time.events.remove(blueEnemyLaunchTimer);
+    boss.kill();
+    booster.kill();
+    game.time.events.remove(bossLaunchTimer);
 
     blueEnemies.callAll('kill');
     game.time.events.remove(blueEnemyLaunchTimer);
...
     //  Reset pacing
     greenEnemySpacing = 1000;
     blueEnemyLaunched = false;
+    bossLaunched = false;
 }

Congratulations, you’ve finished the phaser.js game tutorial series!

What next?

The game is quite nicely polished and fun and challenging to play now. However, aside from adding more content like new enemies and weapon upgrades, there are a number of features that we haven’t covered to fully round out the game.

Most importantly, we haven’t added any sound. Adding a soundtrack and sound effects makes a huge difference to the completeness a game, and shouldn’t be left out. We also haven’t considered targeting and optimising for mobile and tablet devices, or preparing the game for various app stores, which is a whole topic of its own.

I intend to create more tutorials to cover these topics and build on where this tutorial ends. I hope this has given some insight into the process of building a game, and the choices and flourishes along the way that give a game character and make it fun to play.

Please post your feedback below, and if this has been helpful, share it with others. Happy coding!