Introduction
As you may know, I am currently developing my own solo indie game, it's called Epoch Rift and it consists on a 2D roguelike platformer in which you can only play as spell casters. It is being built using one of the most well-known JavaScript gaming frameworks: PhaserJS. I picked this framework because while I am familiar with JavaScript, I am not too familiar with the game development scene and how everything works.
In this article, I will let you know how I have built a test driven environment for some of my game logic.
So far, I have been focusing on building the essential systems and logic, basically laying down the groundwork so that in the near future I can start incrementing on it with new game content. As I have learned recently, this is commonly referred to as 'pre-production' stage whereas the next stage consists on re-using the basis I've built so far to add new stuff, like monsters, spells and levels.
Game Logic
One of these systems which was particularly hard to implement is what I call the Portal System. This system was responsible for picking up the Portal tiles from Tiled and randomly assign destinations based on a set of conditions.
Now is the time when I must come clean and let everyone know I am not the best at cracking algorithms. So, the initial implementation of this system was a mess, the resulting code was big, filled with comments to remind myself what was happening.
After a few tweaks and itterations on this, I was ready to refactor the whole thing from scratch, however, this time I was ready to give Test Driven Development a chance.
Test Driven Development (TDD)
If a recruiter reaches out to me with a Job Description that points towards heavy usage of TDD, I tend to discard it.
This is probably because, as a front end developer, I have been traumatized by TDD zealots in the past who insisted that absolutely everything must be tested, going to the extreme of testing the browser's API to check if a button's onClick
is fired, for example.
In my opinion, if you take TDD too seriously, it becomes counter-productive and a waste of everyone's time. I should not need to test if regular HTML <button>
is working properly, for two reasons: 1- The HTML spec is not going to deprecate such a central element anytime soon and 2- In the highly unlikely chance that regular HTML <button>
elements stop working on a certain browser overnight, I am powerless to fix them. The same can be said for game development, I do not want to test something that is already provided by the framework/tool I rely on.
You can probably tell by now I am quite negligent when it comes to writing tests. I prefer to start writing code and test later, and then I test manually, like a tech-illiterate monkey 🐒 However, for these complex systems, I figured I could reverse my usual process and start by writing a few conditions that my system should satisfy, create a simple function that receives parameters and returns an output and then adjust the code of that function until my assertions would pass.
My first proof-of-concept was a CodeSandbox in which I copy-pasted a tiled map json
and tested a few conditions with the obscure console.assert
method. It was fine for a PoC, but for the game itself I needed something more robust. For example, my humble PoC was only testing one static tiled map, I needed to test all maps and I needed to ensure my function would still work if the maps were updated. It was settled, I had to bring testing to my Phaser game.
Vitest
In the past I have worked with several JavaScript testing frameworks, mainly Jest, Enzyme and react-testing-library, however I only used these when testing React applications and the biggest hardship I always faced was setting up the tests. Usually we need to mock most of the stuff required by our React application, be it request calls, functions, or request responses. I did not want to do that. By this time, I was fairly inclined to go with Jest due to my familiarity with it, however...
When I was doing this, I had recently moved the codebase from Webpack to Vite, with amazing results in terms of speed. I totally intend to write an article on this migration, but the point is that the same guys behind Vite had also this testing framework, called Vitest, which was supposed to be better than all frameworks out there while offering compatibility with the Jest API and plugins. What sold it to me was the fact that since I already have Vite configured, Vitest was fairly easy to set-up, whereas had I gone with Jest, I would have spent hours configurating the thing and added a ton of dev-dependencies to transpile my Typescript code.
Vitest vs Jest
If I were to summarize it, I would say that Vitest is Jest built on top of Vite. It is compatible with the widely used Jest API and offers support for Jest Plugins. The difference is that I don't need to add another configuration file for my alias and I don't need to transpile my TypeScript code, because Vitest relies on the same configuration as Vite. Actually setting it up is quite simple.
If you want to compare Vitest with other popular testing frameworks, you can read more on that on their Comparisons page.
Setting Up
First we need to add the package to our dev dependencies. Run any of the following commands, depending on your package manager:
npm install vitest --save-dev
yarn add vitest -D
pnpm add vitest -D
After we have the package, we need to do is install the package and add are a few lines in my vite.config.js
file:
/// <reference types="vitest" />
// ➕ We need to add this for TypeScript support. If you aren't, you don't need this
export default defineConfig({
// ... rest of my config
test: { globals: true, }, // ➕ I'm using globals.If you aren't, you don't need this.
});
You may also want to add a script to our package.json
to run vitest
.
Now we are ready to create our first spec file. Which can be placed anywhere and simply needs to follow the <file>.spec.ts
naming convention.
Testing Game Logic
So let's go back to my game's Portal system. As you've guessed, it eventually evolved from CodeSandbox and was introduced to my Portals
class. For context, this class is responsible for picking up a Tiled layer, rendering the portal sprites on their correct location and what happens when a player interacts with it. As you have probably guessed by now, my big ugly algorithm was part of the player interaction.
Testing this whole thing would require me to create a whole environment for importing Phaser and actually rendering the sprite on <canvas>
. This was not good. Not that it should be hard to setup Phaser in an headless environment, but what good would it do knowing that my sprite was rendering properly, if all I wanted to test was the result of a function that runs inside the sprite class.
Back when I was looking for the best tool to tackle this problem, I stumbeld upon this comment on a Phaser forum. It suggests running the game logic in a webWorker
and only using Phaser to display stuff. I already have some of the game logic isolated in a procs
folder, so I moved the logic into a single function which would split the entire portal destination assignment logic to a single function which would output an object and the sprite would then use that object to deal with the interaction of actually teleporting the player to its destination.
To avoid having to mock anything, I replaced my Phaser.Math
usage with hand made functions since I would have to write them anyway to mock them.
Example
I've been writing about this Portal system for a while now and I still haven't explained what exactly it does.
In Epoch Rift, each level has a certain number of rooms. The player spawns in the same room. Each room has two portals, one origin portal from which the player arrives and a departure portal, which teleports the player to a random origin portal.
My function basically takes all the portals and creates a rooms
object that is later used for reference whenever a player comes interacts with the portal.
Simple, right ?
Let's start by writing fairly simple test cases. Let's assume that assignRoomsLogic
is my function:
⚠ This code is only an example improved for readability. I did not use the real Tiled layers nor the real function result to save space and to make it easier to understand what's going on.
import { describe, it, expect } from "vitest";
import Map from "./map.json";
import { assignRoomsLogic } from "./Portals";
describe("Logic for assigning rooms to portals", () => {
const portals = getPortalsFromMap(Map);
const rooms = assignRoomsLogic(portals);
it(`${Map.name} 📌 has one spawn point`, () => {
expect(
portals.filter((portal) => portal.isSpawn).length
).toBe(1);
});
it(`${mapName} 📌 has the same amount of origin & departure portals`, () => {
const originPortals = portals.filter((portal) =>
portal.origin
);
const departurePortals = portals.filter(
(portal) => !portal.origin
);
expect(originPortals.length).toBe(departurePortals.length);
});
});
If you have worked with Jest, this should look familiar. Inside a describe
I am basically creating my test case, I get all the portals
from the map and create the resulting rooms
. At this point, I am just ensuring that I did not fuck up when creating the Tiled map.
In the first case I check that only one of the layer tiles has the isSpawn
property. On the second case checking we have the same number of origin and non-origin (departure) portals. Let's proceed with testing the assignRoomsLogic
functionality:
it(`${mapName} 📌 has the same amount of rooms as the portals`, () => {
const uniqRoomsInPortals = [
...new Set(
portals
.filter((p) => p.room.id)
),
];
expect(Object.keys(rooms).length).toBe(uniqRoomsInPortals.length);
});
it(`${mapName} 📌 has all portals leading to a room`, () => {
expect(
Object.values(rooms).filter((r: PortalRoom) => Boolean(r.departure))
.length
).toBe(Object.keys(rooms).length);
});
it(`${mapName} 📌 has the spawn portal as the last destination`, () => {
const gotoPortalIfNotSpawn = (room: PortalRoom) => {
...
return room.id;
};
const spawnRoom: PortalRoom = Object.values(rooms).find(
(r: PortalRoom) => r.isSpawn
);
if (spawnRoom) {
const lastRoom = gotoPortalIfNotSpawn(spawnRoom);
expect(lastRoom).toBe(spawnRoom.id);
}
});
it(`${mapName} 📌 does not have any repeated destination`, () => {
const destinations = Object.values(rooms)
.map((r: PortalRoom) => r?.destination)
.filter((n) => Boolean(n));
expect(new Set(destinations).size).toBe(Object.keys(rooms).length);
});
});
In this snippet, the first test case creates a Set with unique room ids and ensures that there are not any un-reachable rooms. On the second, I confirm that the number of departure portals is the same number of rooms and therefore all of them lead to a room. The third thest case runs a recursive gotoPortalIfNotSpawn
function to go to the next room and stop at the spawn (ie, back at the beginning). And last I assert again with Set
that the unique number of destinations is the same as the rooms.
After this, the I wrapped this in a describe.each, loaded multiple maps and ran the same test cases for all maps:
const LEVEL_MAPS = [...];
describe("Logic for assigning rooms to portals", () => {
describe.each(LEVEL_MAPS)("for each map", (map) => {
const mapName: string = map?.editorsettings?.export?.target;
const portals = getPortalsFromMap(map);
const rooms = assignRoomsLogic(portals);
... // Code from above, using mapName for the output
}
}
Vitest UI
There are a couple of VS Code Extensions for Vitest, which has helped during this process. But I'd say the main feature that made me enjoy working with TDD was Vitest UI.
Like Vitest, this is also a breeze to set up. Simply run yarn add -D @vitest/ui
and add a script to your package.json
to run vitest --ui
(or just do it from the CLI, I am not your father).
The result is a dev server with hot reload that watches over your .spec
files and runs the suites whenever you update something.
To the TDD zealots, I have to concede, when all of those go green for the first time, it is quite the pleasing sensation ✨
Other Use Cases
After I was done with this I have already used this approach to refactor some code and will also apply it in some upcoming features for the game.
Another good example, albeit a bit more straightforward to test is the player state logic. I wrote some basic conditions that test, for example, that a player can't shoot a spell if he's jumping.
I also intend to test the monster's aggro logic to ensure they're properly interacting when in proximity with the player, and ensure that the loot table generation algorithm is giving out correct results.
Every game is a bit different and will have different systems and different game logic requirements. Board and puzzle games may want to ensure their initial board is correctly initialized and meets certain conditions, for example, or enemy and difficulty algorithms for a different number of game genres.
The important thing is that we isolate what we want to test and what should be handled by the framework/engine.
Conclusion
This is a no-bullshit, no-time-wasting approach to testing logic and systems in your JavaScript games. The experience so far has been really smooth and intuitive. Vitest is a great tool, fast and compatibility with the widely known Jest framework, and if you are already using Vite to build your project, I cannot really recomend anything else. If you are like me and like to develop with HMR, Vitest UI is also a great tool to work with tests.
I hope this was a fun and informative read. If you want to read and learn more about me, be sure to check out my website with my past posts. If you want to learn more about Epoch Rift, checkout its official website and follow us on Twitter.
Do you use a different approach to to game development testing ? Do you know any other alternatives to Vitest/Jest ? Feel free to share your thoughts in the comments 💪
Top comments (0)