Space shooter game in JavaScript using Pixi.js

Muhammad Saqib Ilyas
25 min readMay 12, 2024

--

In another article, I showed you how to create a platformer game in JavaScript using the Pixi.js and Matter.js libraries. I also wrote another article showing how to create a clone of Doodle Jump. These articles used a skeletal project structure discussed in this one.

Let’s continue our journey and create a space shooter game using Pixi.js. We start with the same project skeleton code from before. You may clone it from this github repository.

Here’s a preview of the game before implementing shooting. You can play it live here.

A preview of what we’ll build in this blog

The plan

We’ll create a game in which you’ll see a star field scrolling in the background. On the left will be a space craft which serves as our hero. You’d use the up and down arrow keys to move the space ship up and down the screen. From the right enemy space ships will appear at random intervals at random heights on the screen. Each fighter will move across the screen at a constant speed and disappear on the other side, unless it is destroyed en-route. You may shoot at them by pressing the space bar on your keyboard. If our ammo hits an enemy fighter, it gets destroyed. If an enemy fighter hits the hero, it dies. We’ll play an engine sound in the background. There’ll be a sound effect when a space craft is destroyed, as well as when a shot is fired.

Project template

First, download or clone the project template from this github respository. In short, the following directories are important:

  • webpack: This directory contains the webpack build configuration files.
  • src/scripts/system: This directory contains the game engine, i.e., code that is common to all games created with this skeleton.
  • src/scripts/game: This directory contains the game-specific code.

Install the physics engine

In this game, we don’t need gravity and friction, but I felt lazy and installed matter-js, anyway. Modify the package.json file and add the required dependency.

{
// Other contents of package.json
"dependencies": {
// Other dependencies
"matter-js": "^0.19.0",
},
}

Now, run the command npm install in the top-level directory to install Matter.js.

The assets

Download free game assets (the sprite images and sounds) from open game art or any other similar website. I recommed using .png files for images and .mp3 files for the sounds. You may create your own sprites in a Microsoft Paint like program, if you want. You’ll need:

  • A star field image
  • A couple of images for the hero and the enemy space craft
  • Accompanying series of images of engine exhaust
  • A series of images for an explosion
  • A series of images for a shot being fired

Place these files in the src/sprites directory. Make sure that the star field image is named bg.png and it overwrites the file with the same name already in the project src/sprites directory. Similarly, name the hero space craft image hero.png, and the enemy space craft fighter.png.

At this point, if you run the game by typing npm start in the top-level game directory, you should already see a scrolling star field on the screen. Yayyyy!

Displaying the hero

Create a file name Hero.js in the src/scripts/game directory. We start with the following code:

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
constructor() {
this.location = {x: 20, y: Math.floor(window.innerHeight / 2)}
this.createSprite();
this.createBody()
}

createSprite() {
this.container = new PIXI.Container()
this.container.x = this.location.x
this.container.y = this.location.y

this.sprite = new PIXI.AnimatedSprite([
App.res("exhaust1"),
App.res("exhaust2"),
App.res("exhaust3"),
App.res("exhaust4")
]);
this.sprite.loop = true;
this.sprite.animationSpeed = 0.1;
this.sprite.play();

this.shipSprite = new PIXI.Sprite(App.res('hero1'))
this.container.addChild(this.sprite, this.shipSprite)
}

createBody() {
this.body = Matter.Bodies.rectangle(this.container.x + this.shipSprite.width / 2, this.container.y, this.shipSprite.width, this.shipSprite.height, {friction: 0})
Matter.Composite.add(App.physics.world, this.body)
this.body.gameHero = this
}
}

We define and initialize the hero’s location in the constructor method. We want to set the hero 20 pixels from the left, and vertically centred on the screen. We define two methods, one to create the visual on the screen and the other to create the Body of the hero that the Matter-js physics engine will use for collision detection.

We need to display an animated engine exhaust as well as the space craft. The Pixi.js Container object is a handy way to group together such related sprites so that they are placed on the screen and animated together. In the createSprite() method, we create the Container object. The location property set for the Container is for its top-left corner, with x values increasing to the right and y values downward. As we’ll see shortly, the location of the sprites within the Container are also for their top-left corners relative to the Container’s top-left corner.

My series of exhaust images were four in number. To show the exhaust animated on the screen is the job for AnimatedSprite class in Pixi.js. The constructor for this class accepts an array containing the sprite Texture objects in the order in which they should be displayed. To obtain the Texture objects, we query the Application class instance from our project skeleton using its res() method which is short for resource. In short, App.res('exhaust1') returns the Texture corresponding to a file named exhaust.png in the src/sprites directory. We store the created AnimatedSprite object in the object attribute named this.sprite. The this.sprite.loop = true indicates to Pixi.js that we want this sequence of images to be animated continuously in a loop. We set the animation speed for this sprite and ask Pixi.js to start playing the animation right away.

Meanwhile, we create a Sprite object for the space ship and store it in the object attribute this.shipSprite. We add the AnimatedSprite object for the exhaust and the Sprite object for the ship to the Container. Next, we position the exhaust and the ship sprites location within the container.

If we don’t set any x or y properties for these sprites, they’ll both be positioned with their top-left corners overlapping the Container’s top-left corner. Since the shipSprite was added later to the Container, and it is bigger than the exhaust, it will occlude the exhaust. Furthermore, putting the Container with its top-left corner at the vertical center of the screen doesn’t exactly center the ship vertically on the screen. Let’s solve both of these problems one by one.

The following figure shows the first problem and its solution.

Solving the exhaust occlusion problem

The ship is facing to the right, and the exhaust is supposed to be to the left of the ship. The left edge of the Container is where x is 0. Since that’s where the exhaust is supposed to start, we set this.sprite.x to 0. The ship image starts where the exhaust ends, so we set this.shipSprite.x to this.sprite.width. This solves the occlusion problem. The code modification is shown below.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
/* Other code */

createSprite() {
/* Other code */

this.shipSprite.x = this.sprite.width
}
}

Now, let’s try to center the exhaust and the ship vertically on the screen. The following figure shows the problem and its solution.

The vertical centering problem and its solution

We’d like the exhaust to be vertically in the centre of the Container, because my hero image had the exhaust in its vertical centre. So, we set this.sprite.y to -this.sprite.height / 2, i.e., half of the image’s height. This displaces the exhaust image a few pixels upward. The value for this.shipSprite.y is similar to the corresponding setting for the exhaust. Finally, you’ll notice that we add the two sprites to the Container object. The code modification is shown below.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
constructor() {
this.location = {x: 20, y: Math.floor(window.innerHeight / 2)}
this.createSprite();
this.createBody()
}

createSprite() {
/* Other code */
this.sprite.x = 0
this.sprite.y = -this.sprite.height / 2
this.shipSprite.x = this.sprite.width
this.shipSprite.y = -this.shipSprite.height / 2
}
}

Now, we need to modify the GameScene class in src/scripts/GameScene.js to add the hero to the default game scene.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Hero } from "./Hero";

export class GameScene extends Scene {
create() {
this.createBackground();
this.createHero();
}

createBackground() {
this.bg = new Background();
this.container.addChild(this.bg.container);
}

update(dt) {
super.update(dt)
this.bg.update(dt.deltaTime);
this.interval += dt.deltaTime
}

createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.container);
}
}

We import the Hero class. We define a createHero() method and call it from the create() method. In the createHero method, we instantiate a Hero object and store it in an object attribute. We add this object to the GameScene object’s Container. Our hero is hereby part of the game scene. Everything else is unchanged.

If you run the game at this point, by issuing the npm start command, you should see the hero spacecraft and the exhaust animated in the centre of the screen near the left edge. Great!

Engine sound

Let’s play the engine sound in the background. We can start playing the sound when we create the hero. We import the @pixi/sound module add a single line of code to GameScene.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Hero } from "./Hero";
import {sound} from '@pixi/sound'

export class GameScene extends Scene {
/* Other code*/

createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.container);
this.engineSound = sound.play('engine', {loop:true})
}
}

We use the sound.play() method from the @pixi/sound module with engine as the first argument, and it picks up the engine.mp3 file from the src/sprites directory and plays it. The second argument is an object with the loop key set to true to indicate that we want this sound to played continuously in a loop. Now, if you run the game, you’ll hear the engine sound in the background.

Enabling hero movement

Now, let’s allow the player to move the hero up and down the screen with the up and down arrow keys.

First, we modify src/scripts/system/Tools.js to include the keyboard event handler registration. We’ll keep the code generic so that it can be used to register up and down key event handlers for any key on the keyboard. This generic function (source: kittykatattach) accepts the name of a key on the keyboard and returns an object with configured up and down key event handlers.

export class Tools {
/* Other code */
static keyboard(value) {
const key = {};
key.value = value;
key.isDown = false;
key.isUp = true;
key.press = undefined;
key.release = undefined;
//The `downHandler`
key.downHandler = (event) => {
if (event.key === key.value) {
if (key.isUp && key.press) {
key.press();
}
key.isDown = true;
key.isUp = false;
event.preventDefault();
}
};

//The `upHandler`
key.upHandler = (event) => {
if (event.key === key.value) {
if (key.isDown && key.release) {
key.release();
}
key.isDown = false;
key.isUp = true;
event.preventDefault();
}
};

//Attach event listeners
const downListener = key.downHandler.bind(key);
const upListener = key.upHandler.bind(key);

window.addEventListener("keydown", downListener, false);
window.addEventListener("keyup", upListener, false);

// Detach event listeners
key.unsubscribe = () => {
window.removeEventListener("keydown", downListener);
window.removeEventListener("keyup", upListener);
};

return key;
}
}

We create a key object and set its value property equal to the key passed as argument. We maintain the key state in isDown and isUp properties. By default no key is pressed on the keyboard so we initialize isUp to true and isDown to false. The press property will hold the key down event handler, while the release property will hold the key up event handler. We initialize these to undefined at first. The calling function can set these properties later. We define the downHalnder() and upHandler() methods to check, in the case of a keyboard down or up event, respectively, if the key released or pressed, respectively, is the key for which this object is configured. If so, we modify the isUp and isDown states. Also, we invoked the key press or release handlers configured by the calling function. We use the addEventListener() method to register the two event handlers. We define an unsubscribe() method to allow the calling function to unsubscribe from the key events. This can be useful when you want to disable key response in the game while you display a splash screen, for example.

Now, we need to use this to register the up and down arrow key event handlers so that we can move our hero space ship on the screen. We modify the GameScene class.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Hero } from "./Hero";
import {sound} from '@pixi/sound'
import * as Tools from "../system/Tools";

export class GameScene extends Scene {
/* Other code */

createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.container);
this.up = Tools.Tools.keyboard('ArrowUp')
this.up.press = this.hero.moveUp.bind(this.hero)
this.up.release = this.hero.straighten.bind(this.hero)
this.down = Tools.Tools.keyboard('ArrowDown')
this.down.press = this.hero.moveDown.bind(this.hero)
this.down.release = this.hero.straighten.bind(this.hero)
this.engineSound = sound.play('engine', {loop:true})
}

We import the Tools.js file near the top of the file. In the createHero() method, we call the keyboard() method with ArrowUp and ArrowDown as arguments. We store the objects returned by these calls in the up and down variables. We set the key up and key down event handlers with these objects to point to methods of the Hero class. Now, let’s define these methods, namely, moveUp(), moveDown(), and straighten().

export class Hero {
constructor() {
this.velocity = App.config.hero.velocity
this.update = this.update.bind(this)
App.app.ticker.add(this.update)
/* Other code */
}

/* Other code */

moveUp() {
if (this.dy != 1) {
this.dy = 1
}
}

moveDown() {
if (this.dy != -1) {
this.dy = -1
}
}

straighten() {
this.dy = 0
}

update(dt) {
const increment = (this.dy * this.velocity * dt.deltaTime)
if (!this.shipSprite || (this.dy === 1 && this.body.position.y - this.shipSprite.height / 2 <= -increment)) {
return
}
if (!this.shipSprite || (this.dy === -1 && window.innerHeight - this.body.position.y - this.shipSprite.height / 2 <= -increment)) {
return
}
if (this.sprite) {
Matter.Body.setPosition(this.body, {x: this.body.position.x, y: this.body.position.y - increment})
this.container.y = this.body.position.y
}
}
}

In the constructor, we load the velocity property from the Config object. We’ll define this shortly. Based on this, we compute a dy property, which can be +1, 0, or -1. If it is 0, the space craft doesn’t move vertically. If it is +1, the space craft should move up, or if it is -1, it should move down. The moveUp(), moveDown(), and straighten() methods simply set the value of the dy property. We register an update() method with the Pixi.js Ticker object so that it is invoked periodically. In the update() method, we examine the value of dy and move the space ship Container up or down on the screen.

The update() method is called over and over, and we receive an argument that holds the time elapsed since the last invocation. We use this, the ship’s velocity, and the direction dy to estimate the distance that the ship should travel in number of pixels. Then, if the ship is to move upward, we make check if it has reached the top edge of the screen. If that is so, then it doesn’t matter if the player is holding the up arrow key, the ship shouldn’t move upward. Thus, we return from the function without changing anything. Similarly, we check if the ship has already reached the bottom edge. If none of these conditions is true, we change the location of the sprite property, as well as the corresponding Body object in the Matter-js world.

Finally, let’s modify the Config object in src/scripts/game/Config.js.

export const Config = {
/* other code */
hero: {
velocity: 2
}
}

Now, if you run the game, you should be able to move the space ship up and down with the up and down arrow keys.

Displaying an enemy

Displaying the enemy is similar to displaying the arrow. The differences are:

  • The enemy doesn’t need keyboard control
  • The enemy moves across the screen
  • The enemy should appear at a random offset from the top of the screen

Let’s define that class in src/scripts/game/Fighter.js.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Fighter {
constructor(x, y, velocity) {
this.location = {x: x, y: y}
this.velocity = velocity
this.createSprite();
this.createBody()
this.dy = 0
}

createSprite() {

this.container = new PIXI.Container()

this.sprite = new PIXI.AnimatedSprite([
App.res("exhaust1"),
App.res("exhaust2"),
App.res("exhaust3"),
App.res("exhaust4")
]);
this.shipSprite = new PIXI.Sprite(App.res('Ship1'))

this.sprite.loop = true;
this.sprite.animationSpeed = 0.1;
this.sprite.play();

this.container.addChild(this.sprite, this.shipSprite)
this.container.x = this.location.x
this.container.y = this.location.y
this.shipSprite.y = -this.shipSprite.height/2
this.sprite.x = this.shipSprite.width
this.sprite.y = -this.shipSprite.height/2
}

createBody() {
this.body = Matter.Bodies.rectangle(this.container.x + this.container.width / 2, this.container.y, this.container.width, this.container.height, {friction: 0})
Matter.Composite.add(App.physics.world, this.body)
this.body.gameFighter = this
}

move(dt) {
const increment = (this.velocity * dt.deltaTime)

if(this.body) {
if (this.body.position.x < -this.container.width) {
App.app.ticker.remove(this.update, this)
Matter.Composite.remove(App.physics.world, this.body)
this.shipSprite.emit("gone")
this.container.destroy()
this.body = null
}
else {
this.container.x -= increment
Matter.Body.setPosition(this.body, {x: this.body.position.x - increment, y: this.body.position.y})
this.container.x = this.body.position.x - this.container.width / 2
}
}
}
}

It is quite similar to the Hero class. The positioning in the createSprite() is different because the enemy ship moves right to left and the exhaust is to be on the right, as opposed to the left in case of the hero.

We have a move() method which creates the motion across the screen. We’ll need to call it periodically, somehow. We’ll get to that in a bit. But, we rely on the dt argument which we used in the Hero movements, and calculate the increment in the x position of the ship based on its previous location, time elapsed since the last refresh, and the velocity. Again, we move both the Matter.js Body as well as the sprite. We check if the fighter has scrolled across the screen. If so, we remove it from the physics engine because this fighter no longer plays a role in the game. We also emit a gone message, which we’ll handle in a bit.

We need to get the Fighter displayed through the GameScene class. But, rather than show one enemy ship, let’s create another class that manages a the display and movement of a bunch of enemy ships. We create a class in src/scripts/game/Fighters.js.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Fighter } from "./Fighter";
import * as Matter from 'matter-js'

export class Fighters {
constructor() {
this.fighters = [];
this.container = new PIXI.Container();
this.velocity = App.config.fighter.velocity
this.numChannels = 9
this.channelWidth = Math.floor(window.innerHeight/this.numChannels)
this.createFighter(this.getRandomData(), this.velocity)
this.interval = 0
this.nextSpacing = Math.floor(Math.random()*( App.config.fighter.maximumSpacing - App.config.fighter.minimumSpacing)) + App.config.fighter.minimumSpacing
}

createFighter(data) {
const fighter = new Fighter(data.x, data.y, this.velocity);
this.current = fighter
this.container.addChild(fighter.container);
this.fighters.push(fighter)
fighter.shipSprite.once('gone', (f) => {
const index = this.fighters.findIndex( (f) => f.shipSprite === fighter.shipSprite)
if (index !== -1) {
this.fighters.splice(index, 1)
}
})
}

getRandomData() {
const channel = Math.floor(Math.random() * this.numChannels - Math.floor(this.numChannels / 2))
const location = {x: window.innerWidth - 20, y: window.innerHeight / 2 + channel * this.channelWidth}
return location
}

update(dt) {
this.interval += dt.deltaTime
if (this.interval > this.nextSpacing) {
this.createFighter(this.getRandomData(), this.velocity);
this.interval = 0
this.nextSpacing = Math.floor(Math.random()*( App.config.fighter.maximumSpacing - App.config.fighter.minimumSpacing)) + App.config.fighter.minimumSpacing
}
this.fighters.forEach(fighter => fighter.move(dt));
}
}

In the constructor, we create an empty fighters array. As we create enemy fighters, we’ll add them to this array, and we can remove them from this array once they are destroyed or scroll out of view. We create a Pixi.js Container object to hold the fighter sprites. We load the enemy fighter velocity from the configuration file. We’ll define this attribute in it, shortly.

I want the enemy fighters to travel straight in different lanes or channels. I define the number of channels as a constant set to 9. I calculate the height of each channel by dividing the screen height by the number of channels.

I call a createFighter() method to create a single fighter, passing it the ship velocity and some random data, thanks to a getRandomData() method. The getRandomData() method generates an initial x axis location and a random y location. The x axis location is set to slightly outside the right edge of the screen, so that the enemy fighter is initially not visible, and as it moves, it will enter into the view. For the y location, we draw a random integer between -4 and 4. Why these limits? It is because we have 9 channels, if one is smack in the vertical centre of the screen, then there should be four channels above the central channel and four below it. So, if we add this integer times the channel height and add it to the y coordinate of the vertical centre of the screen, then we will get the y coordinate of one of the nine channels.

With these x and y coordinates, we create a Fighter object, and store its reference in the current attribute. This attribute holds the reference to the “current” enemy fighter, i.e., the one that is the centre of attention for now. We add this object to the Container and add the corresponding object to the fighters array. We also subscribe an event handler to listen to the gone message which a fighter emits once it has scrolled beyond the screen. Once we receive that message, we remove the corresponding fighter from the fighters array. Otherwise, the fighters array will keep increasing and slow our game down.

Back in the constructor, we initialize an interval attribute to 0, and calculate a random amount of time after which the next enemy fighter should appear on the screen. This is a random number between two number minimumSpacing and maximumSpacing which we’ll define in the configuration.

We define an update() method which will be called periodically. In this method, we update the update attribute to hold the time elapsed since the “current” fighter was spawned. We compare this duration against the random amount of time we had selected to spawn the next fighter, and if it is time to create another fighter, we call the createFighter() method to do so. It creates a new Fighter object, adds it to the Container, sets it as the “current” fighter, and adds the object to the fighters array. We reset the interval attribute and pick the time until the next fighter is to be created.

Finally, we iterate over the fighters array and call the move() method of the Fighter objects so that the enemy fighters scroll across the screen. Now, we need to hook this class to something that displays everything on the screen, and periodically triggers the update() method.

/* Other imports */
import { Fighters } from "./Fighters";

export class GameScene extends Scene {
create() {
/* Other code */
this.createFighters()
}

/* Other code */

update(dt) {
super.update(dt)
this.bg.update(dt.deltaTime);
this.interval += dt.deltaTime
this.fighters.update(dt)
}

/* Other code */

createFighters() {
this.fighters = new Fighters()
this.container.addChild(this.fighters.container)
}
}

We need to import Fighters to the GameScene class. Then, from within the create() method, we call a createFighters() method, which creates a Fighters object, adds its Container to the GameScene class’ Container. Finally, from with the update() method, we call the update() method of the Fighters class.

Let’s add the required configuration to Config.js

/* Imports */
export const Config = {
/* Other code */
fighter: {
velocity: 2,
probability: 0.015,
minimumSpacing: 250,
maximumSpacing: 300
}
}

Fantastic! Now, if you run the program, you should see the enemy fighters randomly appear and scroll across the screen. Now, we need to make sure that if the enemy fighter collides with the hero, they both explode.

Collision and explosion

We already create the Body objects corresponding to the hero and each of the enemy fighters. The physics engine will take care of the collision detection. We just need to listen to these events and handle them appropriately. Since we have access to all the sprites in the GameScene class, that’s where we’ll handle this.

/* Imports */
export class GameScene extends Scene {
create() {
/* Other code */
this.registerEvents()
}

registerEvents() {
this.boundOnCollisionStart = this.onCollisionStart.bind(this);
Matter.Events.on(App.physics, 'collisionStart', this.boundOnCollisionStart)
}

onCollisionStart(event) {
const colliders = [event.pairs[0].bodyA, event.pairs[0].bodyB]
const hero = colliders.find((body) => body.gameHero)
const fighter = colliders.find((body) => body.gameFighter)
const fighterIndex = colliders.findIndex( (body) => body.gameFighter)
if (hero && fighter) {
this.engineSound.stop('engine')
this.explode(colliders[fighterIndex].gameFighter)
}
}

explode(fighter) {
fighter.explode()
this.hero.explode()
this.up.unsubscribe()
this.down.unsubscribe()
}
}

We call a registerEvents() method from the create() method. In the registerEvents method we register an event listener for the collisionStart event of the physics engine. We use a bound instance method as the event-listener so that it can correctly access the object attributes using the this pointer.

In the event-listener, we access the colliders involved in the collision and check if one of them is the hero and the other a fighter. If it is so, we stop the engine sound, and call the explode() method with the fighter object as argument. We already have the hero object reference stored as this.hero, so we don’t have to pass it to explode(). In explode(), we call the explode() methods of the hero and the fighter. Finally, we unsubscribe from the up and down arrow key handlers. Let’s define the explode() method in the Hero and Fighter classes.

I placed the explosion sprite images explosion1_1.png, explosion1_2.png, …, explosion1_11.png and an explosion sound explosion.mp3 in the src/sprites directory. We’ll play the sound and the explosion animation over the hero and fighter sprites in the explode() method.

/* Imports */

export class Hero {
/* Other code */

explode(fighter) {
sound.play('explosion')
Matter.Composite.remove(App.physics.world, this.body)
App.app.ticker.remove(this.update)
let names = []
for (let i = 1; i < 12 ; i++) {
const name = `Explosion1_${i}`
names.push(App.res(name))
}
this.flameSprite = new PIXI.AnimatedSprite(names);
this.flameSprite.loop = false;
this.flameSprite.animationSpeed = 0.1;
this.flameSprite.play();
this.flameSprite.position.x = this.sprite.width + this.shipSprite.width / 2 - this.flameSprite.width / 2
this.flameSprite.position.y = - this.flameSprite.height / 2
this.container.addChild(this.flameSprite)
App.app.ticker.add(this.halfFlame)
this.flameSprite.onComplete = this.flameGone
}

halfFlame() {
if (this.flameSprite.currentFrame >= 4) {
this.destroy()
App.app.ticker.remove(this.halfFlame)
}
}

flameGone() {
this.flameSprite.destroy()
this.container.destroy()
this.shipSprite.emit("die")
this.sprite.destroy()
this.shipSprite.destroy()
this.sprite = null
this.shipSprite = null
}

destroy() {
if (this.sprite) {
this.sprite.visible = false
this.shipSprite.visible = false
}
}
}

In the explode() method of class Hero, we play the explosion sound. We remove the Body of the hero from the physics engine because it has now officially been destroyed. We remove the update() method from the Ticker object since we no longer need to refresh the hero on the screen. We create an array, named names, containing the names of the explosion animation images. We create an AnimatedSprite() object out of this array of Texture objects. We set the loop property to false because we want the explosion to play just once. We set an animation speed on this object, and play it.

We will add the explosion animation to the Hero object’s Container. We want to position the explosion at the centre of the ship. That means that the its x coordinate should be set to clear the exhaust sprite’s width, as well as half of the space ship sprite’s width. But that puts the explosion’s top-left corner x coordination at the centre of the ship. So, the explosion will appear to be closer to the space ship’s front, not the centre. To correct this, we subtract half of the explosion sprite’s width. The y coordinate setting should be obvious by now. We, then, add the explosion sprite to the Container. The explosion animation is such that it starts small, grows for the first 5 frames, and then starts to reduce until it disappears. When the explosion is at full intensity, I want the space ship and the exhaust images to disappear to indicate that it has disintegrated. For that reason, we register an event listener named halfFlame() to the Ticker object. If five frames have played out, this method removes itself from the Ticker, and calls a destroy() method. In destroy(), we make the space ship and exhaust sprites invisible.

Back to the explode() method. Once the explosion animation has completed playing, its last frame will remain on the screen. But that isn’t what we want. In order to remove that last frame from the screen, we register a callback to the Complete event of the AnimatedSprite object. The event handler, named flameGone(), calls destroy() on all the assets and emits a die event off the shipSprite object. By registering to listen to this event, we can display a game over screen, or restart another round.

/* Imports */

export class GameScene extends Scene {
/* Other code */
createHero() {
/* Other code */
this.hero.shipSprite.once('die', ()=> {
Matter.Events.off(App.physics, 'collisionStart', this.boundOnCollisionStart);
this.hero = null
this.fighters.destroy()
this.fighters = null
App.scenes.start('Game')
})
}
}

In the createHero() method in GameScene, we register an anonymous function as the die event listener. In that function, we turn off the collision detection in the physics engine, set this.hero to null to indicate that our hero is gone. We call the destroy() method on our fighters object, and set that reference to null. Finally, we restart the game.

We need to modify the Fighters class to add the destroy() method, and we also need to display the explosion in the Fighter class. The explosion in the Fighter class can be done exactly how we did it for the Hero class.

/* Imports */

export class Fighter {
/* Other code */

explode(fighter) {
Matter.Composite.remove(App.physics.world, this.body)
App.app.ticker.remove(this.update)
let names = []
for (let i = 1; i < 12 ; i++) {
const name = `Explosion1_${i}`
names.push(App.res(name))
}
this.flameSprite = new PIXI.AnimatedSprite(names);
this.flameSprite.loop = false;
this.flameSprite.animationSpeed = 0.1;
this.flameSprite.play();
this.flameSprite.position.x = this.shipSprite.width / 2 - this.flameSprite.width / 2
this.flameSprite.position.y = - this.flameSprite.height / 2
this.container.addChild(this.flameSprite)
App.app.ticker.add(this.halfFlame)
this.flameSprite.onComplete = this.flameGone
}

halfFlame() {
if (this.flameSprite.currentFrame >= 4) {
this.destroy()
App.app.ticker.remove(this.halfFlame)
}
}

flameGone() {
this.flameSprite.destroy()
this.container.destroy()
this.sprite.destroy()
this.shipSprite.destroy()
this.shipSprite.emit('gone')
this.sprite = null
this.shipSprite = null
}

destroy() {
if (this.sprite) {
this.sprite.visible = false
this.shipSprite.visible = false
}
}
}

The above modifications should be obvious by now. Note that in the flameGone() method, we emit a gone message. We already have an event listener on it in GameScene which removes the corresponding Fighter object from the fighters array. Since the game is to restart, all the other fighters should also be disposed off. That’s why we called this.fighters.destroy() from the GameScene class. Let’s implement that method.

/* Imports */

export class Fighters {
/* Other code */
destroy() {
this.fighters.forEach( (fighter) => {
if (fighter.body) {
Matter.Composite.remove(App.physics.world, fighter.body)
fighter.destroy()
}
})
this.container.destroy()
}
}

We remove all the fighter bodies from the physics engine, and call destroy() on them so that they disappear from the screen. Finally, we destroy the Container object.

Now, if you run the program, and collide the hero with an enemy fighter, you should see them both explode, and the game will restart.

Shooting

I have a series of 4 shooting images named shot1_1.png, shot1_2.png, shot1_3.png, and shot1_4.png. I place these in the src/sprites directory. I’ll hook up the space bar to firing the shots. We start by modifying the GameScene class to handle space bar key presses.

/* Imports */

export class GameScene extends Scene {
/* Other code */

createHero() {
/* Other code */

this.shoot = Tools.Tools.keyboard(' ')
this.shoot.press = this.shootHandler.bind(this)
}

shootHandler() {
this.hero.initShot()
}
}

We hook up the shootHandler() method to listen to space bar key presses, and delegate the shot firing to the Hero class object. We do this because the location of the shot is determined by the Hero object.

In the Hero class, we define this method as follows.

/* imports */

export class Hero {
constructor() {
/* Other code */
this.shots = []
}

initShot() {
sound.play('laser')
this.shot = new PIXI.AnimatedSprite([App.res('shot1_1'), App.res('shot1_2'), App.res('shot1_3'), App.res('shot1_4')])
this.shot.loop = false
this.shot.animationSpeed = 1
this.shot.play()
this.container.addChild(this.shot)
this.shot.x = this.sprite.width + this.shipSprite.width
this.shot.y = - this.shot.height / 2
}
}

We declare an empty array named shots to hold all the shots that we fire. In the initShot() method we play a laser shot sound, and create an AnimatedSprite based on the shot images. The four images appear like there’s some sort of arc build up eventually culminating in a shot. We want to display this initial build up at the tip of the space ship, before the shot starts traveling towards the right. We play the animation and add the AnimatedSprite to the Hero class’ Container. We position the AnimatedSprite at the tip of the space ship.

Let’s also unsubscribe from the space bar key handlers when the fighter gets destroyed.

/* Imports */
export class GameScene extends Scene {
/* Other code */

explode(fighter) {
/* Other code */
this.shoot.unsubscribe()
}
}

Now, if you run the program, you should see an animation at the tip of the space ship when you press the space bar. We should now make the shot travel towards the right.

We define a class for the shot.

import * as PIXI from 'pixi.js'
import { App } from '../system/App'
import * as Matter from 'matter-js'

export class Shoot {
constructor(x, y) {
this.container = new PIXI.Container()
this.container.x = x
this.container.y = y
this.velocity = App.config.shot.velocity
this.createShot()
this.createBody()
}

createShot() {
this.sprite = new PIXI.Sprite(App.res('shot1_4'))
this.container.addChild(this.sprite)
}

createBody() {
this.body = Matter.Bodies.rectangle(this.container.x + this.sprite.width / 2, this.container.y, this.sprite.width, this.sprite.height, {friction: 0})
Matter.Composite.add(App.physics.world, this.body)
this.body.gameShot = this
}

update(dt) {
const increment = this.velocity / dt.deltaTime
this.container.x += increment
if (this.sprite) {
if (this.body.position.x > window.innerWidth) {
Matter.Composite.remove(App.physics.world, this.body)
this.sprite.emit('beyond')
this.sprite.destroy()
}
Matter.Body.setPosition(this.body, {x: this.body.position.x + increment, y: this.body.position.y})
this.container.y = this.body.position.y
}
}

destroy() {
this.sprite.destroy()
this.container.destroy()
}
}

This class takes over from the initial shot animation of the Hero class. It makes a sprite based on the shot1_4.png image travel towards the right, thanks to the update() method. It also creates a physical Body and adds it to the physics engine so that we can detect collisions of the shot with enemy fighters. It also defines a destroy() method that we can call once the shot hits an enemy fighter. When the shots goes beyond the right edge of the screen, it emits a “beyond” message and destroys itself. Let’s use this class in the Hero class.

/* Other imports */
import { Shoot } from "./Shoot";

export class Hero {
constructor() {
/* Other code */
this.shots = []
}

update(dt) {
/* Other imports */
this.shots.forEach((shot) => shot.update(dt))
}

initShot() {
/* Other code */
this.shot.onComplete = this.completeShot.bind(this)
}

completeShot() {
const location = this.shot.toGlobal(new PIXI.Point(0, 0))
const newShot = new Shoot(location.x, location.y)
this.shots.push(newShot)
newShot.sprite.once('beyond', ()=> {
const shotIndex = this.shots.findIndex(s => s === newShot)
this.shots.splice(shotIndex, 1)
})
this.container.removeChild(this.shot)
App.app.stage.addChild(newShot.container)
}
}

So, we hook up a method completeShot() to run when the animation finishes playing. We use the Sprite class’ toGlobal() method to convert the AnimatedSprite’s relative coordinates to screen absolute coordinates and pass it on the Shoot class constructor. We add the new shot object to the shots array. We hook up an event listener for the beyond message so that we can remove the shot from our shots array. We remove the AnimatedSprite object from the Container before the Shoot class moves the shot across the screen. We add the Shoot class object to the Container.

Now, if you run the program, you should be able to fire shots that travel across the screen and disappear. Let’s now hook up the logic to destroy the enemy fighters once they are hit by a shot.

/* Imports */

export class GameScene extends Scene {
onCollisionStart(event) {
const colliders = [event.pairs[0].bodyA, event.pairs[0].bodyB]
const hero = colliders.find((body) => body.gameHero)
const fighter = colliders.find((body) => body.gameFighter)
const fighterIndex = colliders.findIndex( (body) => body.gameFighter)
const shot = colliders.find( (body) => body.gameShot)
if (fighter) {
const fighterObj = colliders[fighterIndex].gameFighter
if (hero) {
this.engineSound.stop('engine')
this.explodeHeroAndFighter()
}
else {
sound.play('explosion')
this.hero.destroyShot(shot)
this.explodeFighter(fighterObj)
}
}
}

explodeFighter(fighterObj) {
fighterObj.explode()
}

explodeHeroAndFighter(fighter) {
fighter.explode()
this.hero.explode()
this.up.unsubscribe()
this.down.unsubscribe()
this.shoot.unsubscribe()
}
}

We modify the GameScene class code slightly. We detect if the collision involves a Shoot object and a Fighter object. If that is the case, we play an explosion sound. We call a destroyShot() method on the Hero class, and call the explodeFighter() method. Finally, we need to define the destroyShot() method in the Hero class. The purpose of this method is to make the shot disappear since it has destroyed an enemy fighter.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
import {sound} from '@pixi/sound'
import { Shoot } from "./Shoot";

export class Hero {
/* Other code */

destroyShot(shot) {
const index = this.shots.findIndex(s => s.body.id === shot.id)
if (index !== -1) {
this.shots[index].destroy()
this.shots.splice(index, 1)
}
}
}

We use the id attribute of the Body from the physics engine to match the shot to be destroyed. We call the destroy() method of the Shoot class. We also remove the Shoot object from the shots array so that we no longer call updates on it. Now, if you run the game, you should be able to destroy enemy fighters with your weapon.

That’s all folks!

This has been a long blog. I’ll leave you at this point. If you want the complete code, you can get it from this github repository. Let me know if you run into a bug. I’ll continue to work on the score, start screen, and game over. But I’ll let you try that out on your own.

--

--

Muhammad Saqib Ilyas

A computer science teacher by profession. I love teaching and learning programming. I like to write about frontend development, and coding interview preparation