Developing a Platformer in JavaScript with PIXI.JS — Creating a project template

Muhammad Saqib Ilyas
12 min readApr 9, 2024

--

PS: This is part I of a series of two blogs. In this article, I describe the creation of the generic structure of a platformer game in JavaScript. In part II, I show how to create the actual game in this article. You can play the completed game, here.

I’ve also written an article on how to create a Space Shooter game using this approach.

I am on a journey of learning JavaScript by creating several projects. The latest one is to create a platformer game. I looked for tutorials to follow and finally settled for this one. I liked two things about this particular tutorial. First, it creates a proper code structured in classes and split over directories. Second, it has a text-based version here.

As I followed the text-based tutorial, I ran into a problem. When I installed PIXI.js on my machine, I got version 8, whereas the author was using version 5, and there were several changes in PIXI.js API between these versions. I took it up as a challenge to try to port the game to PIXI.js version 8. So far, I have gotten a scrolling background. I thought I’d write about the code I have developed so far and make it available for others. Full attribution to the original author of the game. I am making minimal changes to their code right now.

Project template

The author takes an approach of creating a project template that can be reused to create several games. We create a directory for the game. Inside that directory, we run the command npm init to initialize an empty npm project. This command prompts us for basic information about the project. First, it prompts us for the package name, and picks the name of the directory as the default, which should be fine. Then, it prompts us for the version number of the project, and offers 1.0.0 as the default, which is fine as well. Then, it prompts us to provide a description. Type some text describing what you are about to build. Then, it asks us for the entry point of the project, and offers index.js to be the default, which is fine. This is the file from which our project code starts running. It asks us for a test command, and a git repository, which we leave blank for now. Then, it asks us for keywords. You can type some keywords like game, platformer etc. Then, it asks for the author’s name. Provide your name, here. Then, it asks for the license and offer ISC as the default, which is fine. Then, it shows us the package.json file that it is about to write, and asks if this is OK. Press enter if this is fine.

Creating subdirectories

Next, we create two sub-directories: webpack, and src. We use the webpack directory for configuring the webpack build and run process. We use the src directory to host the code and assets for the game. Inside the src directory, we create a scripts sub-directory to contain the code, and a sprites directory to hold the assets. Inside the scripts directory, we create two sub-directories: system for the code that we’ll need in every game that we create using PIXI.JS, and game for the game-specific code.

Creating the index.html file

We create the following template web page with just some styling and an empty body portion.

<!DOCTYPE html>
<html>
<head>
<title>PixiJS tutorial</title>
<style>
body {
background-color: #000;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
canvas {
position:absolute;
top:50%;
left:50%;
transform: translate(-50%, -50%);
-o-transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
-moz-transform: translate(-50%, -50%);
-webkit-transform: translate(-50%, -50%);
}
</style>
</head>
<body>

</body>
</html>

Configuring webpack

In the webpack directory, we create a base.js file and put the following code in it:

const webpack = require("webpack");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = {
mode: "development",
devtool: "eval-source-map",
entry: "./src/scripts/index.js",
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
},
{
test: /\.(png|mp3|jpe?g)$/i,
use: 'file-loader?name=sprites/[name].[ext]'
}
]
},
plugins: [
new CleanWebpackPlugin({
root: path.resolve(__dirname, "../")
}),
new webpack.DefinePlugin({
CANVAS_RENDERER: JSON.stringify(true),
WEBGL_RENDERER: JSON.stringify(true)
}),
new HtmlWebpackPlugin({
template: "./index.html"
})
]
};

In the first four lines, we are importing some modules. We start with webpack, which is an application bundler. It bundle JavaScript, CSS, and assets for use in a web browser. Next, we import the path module that allows us to manipulate file and directory paths in our code. Next, we import html-webpack-plugin which imports the HTML Webpack Plugin module. This module helps create HTML files with auto-generated script tags for our bundled scripts. Finally, we import the clean-webpack-plugin module. This one cleans the bundled output directory before each build so that it always contains only the artifacts of the most recent build.

Next, we have a module.exports statement that exports a JavaScript object that contains the webpack configuration. Here’s what we are doing in this configuration:

  • We select development mode, rather than production.
  • We configure source mapping, which maps the generated bundled code files to the original source code. With our setting, the source map is included in the bundled code, rather than as a separate file.
  • We declare src/scripts/index.js as the code entry point.
  • Module rules configure how webpack handles various types of files. First, we specify a regular expression that matches any file with a .js file extension, except those that are present inside the node_modules directory to be transpiled by Babel. Think of transpiling as the process of converting JavaScript code from one version to another. One of the things this does is convert code that uses newer JavaScript features to code that is more widely supported by web browsers. We also configure the file-loader module for game asset files such as audio and image files. Here, we instruct the file-loader module to copy any game assets to the sprites directory in the build directory with the original filename and extension intact.
  • Finally, in the plugins portion, we configure some of the plugins. First, we instruct the Clean Web Pack plugin to clear the root directory ../ before each build. Next, we set some constants that can be accessed in our JavaScript code. We declare the use of WebGL, and Canvas renderers in our application. Finally, we set index.html as the named of the HTML template that HTML Webpack Plugin should generate with reference to the bundled code.

Adding package dependencies

Add some dependencies to package.json so that its dependencies and devDependencies sections look like the following:

"dependencies": {
"@babel/preset-env": "^7.24.0",
"fs-extra": "^11.2.0",
"gsap": "^3.10.4",
"pixi.js": "^8.0.2"
},
"devDependencies": {
"babel-loader": "^8.2.5",
"clean-webpack-plugin": "^4.0.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3",
"webpack-merge": "^5.8.0"
}

Modify the scripts section of the file so that it contains the following. We set up the command to run when we trigger a development mode application run.

"scripts": {
"start": "webpack-dev-server --config webpack/base.js --open"
},

Next, run the npm install command to install these dependencies.

Writing the system code

We start by creating an Application class in src/scripts/system/App.js.

import * as PIXI from "pixi.js";

class Application {
run(config) {
this.config = config;
this.app = new PIXI.Application();
this.config.stage = this.app.stage;
this.app.init({ width: window.innerWidth, height: window.innerHeight }).then(()=> {
document.body.appendChild(this.app.canvas);
})
}
}

export const App = new Application();

The run method accepts a config object. We initialize a PIXI.js application and store the reference in the app member. We store the stage attribute in the config object for later use by other classes. The definition of the class for this object appears later. We call the init() method on the application object with width and height parameters passed as an object. This method returns a Promise. When the promise resolves, we append the canvas attribute to the page. Note the Singleton pattern at work as we export an object, not the class itself. Any class in our code can import Application from the above file and have access to a global set of resources and configurations.

Creating an asset loader

Let’s define a class to load the game assets:

import {Assets} from "pixi.js"
export class Loader {
constructor(config) {
this.config = config;
this.sprites = config.loader
this.resources = {};
}

async preload() {
try {
await Promise.all(
this.sprites.map(async (imageModule) => {
let imagePath = imageModule.default;
const texture = await Assets.load(imagePath);
const indexOfSlash = imagePath.lastIndexOf('/')
imagePath = imagePath.substr(indexOfSlash + 1)
const indexOfDot = imagePath.lastIndexOf('.')
imagePath = imagePath.substr(0, indexOfDot)
this.resources[imagePath] = texture; // Store loaded textures
})
);
} catch (error) {
console.error("Error loading assets:", error);
}
}
}

We import Assets package from the PIXI.js library which allows working with game assets. The constructor for our Loader class accepts a config object as parameter. We’ll declare this elsewhere as follows:

import { Tools } from "../system/Tools";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png|mp3)$/)),
}

This relies on a src/scripts/system/Tools.js file. Here’s what it looks like:

export class Tools {
static importAll(r) {
return r.keys().map(key => r(key))
}
}

The Tools class has a static method named importAll(), which relies on an argument in the form of a webpack require.context function, which is passed to it from the Config class. The Config class has a key named loader, that invokes the Tools.importAll() with a require.context function. It creates a context with all the .png files in the src/sprites directory. Feel free to extend the third argument to include any other file types that you want to use. For instance, if you want to use some JPEGs as well, then change it to /\.(png|jpg|jpeg|mp3)$/. The true as the second argument indicates that the context should include any files recursively inside any subdirectories under src/sprites. The Tools.importAll() function gets all the paths in this context using r.keys(). We then use the Array.map() method to invoke the require.context method on each of these paths to effectively import all the .png and .mp3 files in our src/scripts directory into the loader property. Now, any class in our code can use the Loader.loader property to reference any of the game assets.

In the Loader class, we store the Config class object as its config property, and Config.loader as its sprites property. We initialize an empty dictionary named resources. We declare a preload() method to load the game assets when the game starts. For each asset path, we invoke the PIXI.Assets.Load() method, which returns a Promise. The Promise.All() ensures that all assets are loaded concurrently for better performance. We extract the file name part from each asset path and use it as a key to store the loaded textures in the resources dictionary. Now, for instance, we can load the src/sprites/bg.png file as resources['bg'].

Since src/scripts/index.js is configured as the entrypoint, let’s call the Application.run() method there:

import { Config } from "./game/Config";
import { App } from "./system/App";

App.run(Config);

Let’s update the run() method in our Application class to preload the assets:

import * as PIXI from "pixi.js";
import { Loader } from "./Loader"

class Application {
run(config) {
this.config = config;
this.app = new PIXI.Application();
this.config.stage = this.app.stage;
this.app.init({ width: window.innerWidth, height: window.innerHeight }).then(()=> {
document.body.appendChild(this.app.canvas);
this.loader = new Loader(this.config);
this.loader.preload().then(() => this.start());
})
}
start() {

}
}

export const App = new Application();

Once pre-loading is complete, we call the start() method in the Application class. Right now, it is empty. However, now, if we run our code, it pre-loads all game assets.

Starting the game

Let’s modify the Application class start() method to start the game background animation.

import * as PIXI from "pixi.js";
import { Loader } from "./Loader"

class Application {
/* Other implementation */
start() {
this.scene = new this.config["startScene"]()
this.app.stage.addChild(this.scene.container)
}
}

export const App = new Application();

This relies on a key in the Config class.

import { Tools } from "../system/Tools";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png|mp3)$/)),
startScene: Game
}

In our Application class, we invoke the constructor of a class that is configured as the value for the startScene key in the Config class. That way, our game system implementation remains reconfigurable for different games.

Let’s create the Game class that Config refers to.

import * as PIXI from "pixi.js";
import { App } from "../system/App";

export class Game {
constructor() {
this.container = new PIXI.Container();
}
}

This class uses the PIXI.Container class from the PIXI.js library. We will add all sprites for a scene to this container.

Sprite rendering

To render the sprites on the scene, we first add a couple of helper methods to the Application class.

import * as PIXI from "pixi.js";
import { Loader } from "./Loader"

class Application {
/* Other code */
res(key) {
return this.loader.resources[key];
}

sprite(key) {
return new PIXI.Sprite(this.res(key));
}
}

export const App = new Application();

The res() method queries and returns the resources dictionary from the Loader class to return the textures that we pre-loaded. The sprite() method creates a Sprite object based on this texture and returns it.

Background rendering

Let’s use this code to render the background. We modify the Game class as follows:

import * as PIXI from "pixi.js";
import { App } from "../system/App";

export class Game {
constructor() {
this.container = new PIXI.Container();
this.createBackground();
}
createBackground() {
this.backgroundSprite = App.sprite("bg");

this.backgroundSprite.width = window.innerWidth
this.backgroundSprite.height = window.innerHeight

this.container.addChild(this.backgroundSprite);
}
}

We insert a call to the createBackground() method in the constructor. We define the createBackground() method and obtain the background image sprite using the Application.sprite() method with bg passed as the key, since our background image filename is bg.png. We configure the width, height and position of the background sprite, and add it to the scene.

Scene management

We need to switch between scenes at various points during a game. For instance, when the game goes from a splash screen to the game level 0 scene, and when the player goes from one level to the next. Let’s define a scene manager class to handle managing the different scenes and switching between them. We create a base Scene class, first in src/scripts/system/Scene.js.

import * as PIXI from "pixi.js";
import { App } from "./App";

export class Scene {
constructor() {
this.container = new PIXI.Container();
this.container.interactive = true;
this.create();
App.app.ticker.add(this.update, this);
}

create() {}
update(dt) {}
destroy() {}

remove() {
App.app.ticker.remove(this.update, this);
this.destroy();
this.container.destroy();
}
}

We will override the create(), update(), and destroy() methods in a derived class, later. In the base class constructor, we perform operations that any scene class needs. This includes creating a PIXI.Container object, calling a scene create() method and configuring the PIXI game animation ticker to invoke the update() method every so often. In the remove() method, we perform the reverse of these universal operations.

Next, we create a scene manager that actually manages and switches between the scenes.

import * as PIXI from "pixi.js";
import { App } from "./App";

export class ScenesManager {
constructor() {
this.container = new PIXI.Container();
this.container.interactive = true;
this.scene = null;
}

start(scene) {
if (this.scene) {
this.scene.remove();
}
this.scene = new App.config.scenes[scene]();
this.container.addChild(this.scene.container);
}
}

In the SceneManager class we create a scene if it hasn’t been created already. If there’s already a scene, we remove() it and create the next one.

The scene to be launched is specified in the Config class.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true,
scenes: {
"Game": Game
}
}

Now, we modify Game to extend the Scene class.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Scene } from "../system/Scene";

export class Game extends Scene{
/* The implementation from ealrier */
}

We modify the Application class to use the SceneManager class.

import * as PIXI from "pixi.js";
import { Loader } from "./Loader"
import { ScenesManager } from "./ScenesManager";

class Application {
/* Other implementation */
start() {
// this.scene = new this.config["startScene"]();
this.scenes = new ScenesManager();
this.app.stage.addChild(this.scenes.container)
this.scenes.start("Game");
}
/* Other implementation */
}

export const App = new Application();

Creating a scrolling background

We now create the effect of constant movement by animating the background from right to left continuously. We create a GameScene class in src/scripts/game.

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

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

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

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

This class relies on a Background class, defined in src/scripts/game.

import * as PIXI from "pixi.js";
import { App } from "../system/App";

export class Background {
constructor() {
this.speed = App.config.bgSpeed;
this.container = new PIXI.Container();
this.createSprites();
}
createSprites() {
this.sprites = [];

for (let i = 0; i < 3; i++) {
this.createSprite(i);
}
}

createSprite(i) {
const sprite = App.sprite("bg");

sprite.x = sprite.width * i;
sprite.y = 0;
this.container.addChild(sprite);
this.sprites.push(sprite);
}

move(sprite, offset) {
const spriteRightX = sprite.x + sprite.width;

const screenLeftX = 0;

if (spriteRightX <= screenLeftX) {
sprite.x += sprite.width * this.sprites.length;
}

sprite.x -= offset;
}

update(dt) {
const offset = this.speed * dt;
this.sprites.forEach(sprite => {
this.move(sprite, offset);
});
}
}

We create three sprites based on the background image. We position one at x equal to 0. The next one, we position at x equal to the background width, and the last one at x equal to twice the background width. It is as if these are placed side by side on the screen, with the first one visible in front. Then, in the update() method we slowly update the x position of each background sprite a little bit to the left, based on a bgSpeed key to be defined in the Config class. This requires a modification to the Config class.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png)$/)),
bgSpeed: 2,
scenes: {
"Game": GameScene,
}
}

Now, in the console, switch to the project top-level directory (the one above src), and issue the command npm start. You should see a scrolling background.

You may download this starter code from this repository and play with it. To learn about the rest of the work for creating a platformer game using this base code, please read this article.

--

--

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