Developing a Platformer in JavaScript with PIXI.JS — Creating a project template
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 thenode_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 thefile-loader
module for game asset files such as audio and image files. Here, we instruct thefile-loader
module to copy any game assets to thesprites
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 setindex.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.