I don't like Phaser

Art by ふたみーや

I don't like Phaser

I've been spending a lot of time lately working on a game meant to run in your browser. Generally speaking, there are two different approaches you can take for this. The first is to use something like Unity that has an HTML5 export option. The second approach is to build the game natively for the web, using JavaScript. This is the approach I've been taking.

My background is in game development. As a game developer, I care a great deal about being able to control exactly how things work. I want to choose exactly when a sound plays, exactly how loud it is, and which speakers it comes out of. I want full access to the state of any input devices that are available. And of course, I care a great deal about performance. This is fundamentally at odds with the philosophy of the web.

The philosophy of the web is that the browser is the boss. Your app just has to deal. If the browser didn't think that keypress was important, you won't even know it happened. The browser APIs continue to expand, although actually using some of the features can be cumbersome. There's an API to access files on your PC, but it's so obnoxious (and new!) that most apps would rather just have a websocket pointed at a cloud service that harvests your data instead.

I'm okay with this. These fences exist for good reason. But if your app requires access to the other side of the fence, you need to build tools for yourself. There are key differences between how web apps work (and thus, how web APIs work) and how games work. The biggest difference is that web apps work on requests. You press a button and something happens. Or, a server sends a message and something happens. But games operate on loops. You press a button, and the game constantly checks to see if the button is pressed, and then does something. Input code often looks like this:

function update() {
    if (Keyboard.isKeyDown("w")) {
        Jump();
    }

    if (Keyboard.isKeyDown("a")) {
        MoveLeft();
    }

    if (Keyboard.isKeyDown("d")) {
        MoveRight();
    }
    // real input code is obviously going to be more sophisticated than this, but you get the idea.
}

But the browser doesn't let you write this code. You only get an event. Nearly all the browser APIs for anything you might want to do are event-based. Event based input works great for web apps, and sucks for games. Unity's been trying to push event-based input for the last 5 years and I have yet to see it actually work out in production. The one team I know that actually tried using it ultimately ended up writing a polling-based wrapper on top of the event system.

This is fine though. Polling-based wrappers on top of event-based APIs are the bread and butter of game engines. Event based input isn't "bad". The input API for your favorite OS is event-based (though, you often end up polling for the events...?) Popular game engines provide a moment-in-time keyboard state as a useful abstraction. In reality, it's events all the way down to the metal. In the web, you'll need to get used to building lots of abstractions like this.

For those of you familiar with web development, your spidey senses are probably tingling right now. Surely, there must be a package! Obviously someone has written a full-featured game engine that provides all this functionality for us, and we can focus on building the actual game. And you're right! It exists. It's called Phaser.

On our project, we use Phaser. We all hate it. So, what exactly is wrong with Phaser? I'm so glad you asked. Here's my list:

  1. The documentation sucks.
  2. Many of the "abstractions" are worse than the built-in browser API.
  3. Many basic tasks are naive by design and almost impossible to do "safely".
  4. The community.
  5. Phaser plans to make money by sucking.

This list used to be much, much longer, but I had to cut it down because this article was getting way too long. I'm going to go through each of these points with details and examples. Let's begin.

1) Dire Dire Docs

Documentation is important. We all agree, yes? But what makes good documentation? Just kidding, I'm not going to rehash the same thirty talking points you can find copy/pasted into every SEO-optimized chatGPT-powered blog about how to be a better webdev. Instead let's talk about bad documentation. What are some things that can immediately tell you the docs are bad?

This could be its own blog post (and might be in the future) but I believe doc quality can be measured directly in terms of the percentage of your work hours spent writing them. Experimentally, I get the best results with about a 15% doc ratio. That means if I work 7 hours a day productively writing code (which is a number we all definitely achieve every day), I should then spend one hour working on docs. Red flags in another project's documentation are manifestations of laziness. Any time they've clearly taken a shortcut to save time on docs, be very wary. So what are some signs of lazy documentation?

  1. Auto-generated documentation.
  2. Out-of-date documentation.
  3. Disorganized, or arbitrarily-organized documentation.
  4. Marketing disguised as documentation.
  5. Descriptive documentation (instead of prescriptive).

So how do Phaser's docs (which can be found here) do with these criteria?

Screenshot of the Phaser docs. It shows a screen broken into categories like 'Namespaces', 'Classes', and 'Events', and includes a list of classes sorted alphabetically with no other details or explanation.

Oh no.

Okay, so they're a bit disorganized. But maybe the docs themselves are good, and they just-

Screenshot of the 'GameObjectFactory' entry in the Phaser docs. The docs are vague and include a lot of irrelevant information, like protected class members for an internal class.

Seriously? The first thing on the member list is a protected property?

This is not a cherry-picked example. The docs range from mostly useless to completely useless. Why did they even bother? These pages are essentially just the source code without the actual code. If you intend to use Phaser outside the narrow, naive examples they give you, you'll need a local copy of the source you can browse in an IDE because that's the only way to figure out what anything actually does. If Phaser wasn't open source, our team would've given up on it a long time ago.

Either write actual docs, or don't provide any. Don't just list 274 classes in alphabetical order, with no details on any of them besides whatever comments happened to be in the source. You are actively wasting my time. The docs would be improved if they were replaced with a single link to their GitHub repo. But at least the engine itself is decent, right?

2) If you're going to write an API, make it better than what's already in the browser.

For our engine, I wrote a custom input handler. The entire system is under 200 lines of code. And here's how you use it:

/// InputConfig.ts
// first, add your input to the global input map (this allows rebinding keys transparently)
export enum Keybind {
	Interact = 0, // add an entry here
}

export const defaultKeyBinds: Readonly<Record<Keybind, number[]>> = {
	[Keybind.Interact]: [KeyCode.E], // and here
};

/// YourCode.ts
// finally, just use the key in your code:
function update() {
	// ...
	if (Input.isKeyDown(Keybind.Interact)) {
		// interact with something
	}
}

Under the hood, this system has an init() method that loops through all the keys and registers callbacks with the corresponding browser events to update its internal state. The code is repetitive, but it works well and it's easy to extend (rare Copilot W). Another, even simpler option is to listen for the generic "keypress" events and extract the useful information from that. This event is as simple as it gets:

window.addEventListener("keydown", (e) => {
	Input.downKeypressMentioned(e.keyCode);
});

Guess what Phaser's input API gives you access to?

// https://newdocs.phaser.io/docs/3.80.0/Phaser.Input.Keyboard.Events
var spaceBar = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
// What arguments does "listener" take, you may ask? Well, the docs didn't think it was worth mentioning.
spaceBar.on('down', listener);

Readers with short-term memory of at least 10 seconds might notice this looks awfully familiar. And yes, that's right! The Phaser input API is exactly the same as the one in the browser. Except worse, because you need to have a valid scene to attach your input to and you need to register each key. There's a polling-based API you can use, so fear not. Except that you have to make a separate class instance for every key on the keyboard to use it. Very practical!

It's not just input. But I could spend literal hours going through the API and showing how bad it is. Keyboard input is just something so basic, so fundamental, and so easy to get right from an API standpoint that it amazes me they managed to screw it up.

3) Hold my hand or be stupid, but don't hold my hand stupidly

There's nothing inherently wrong with a "stupid" API. If it's my responsibility to not pass invalid things then that's fine by me. But if you're going to make me do that, then you need to give me a good place to add my own safety. Phaser has a huge amount of APIs that take a small amount of input (often just a couple strings) and then do a ton of work (asset loading, rendering, etc) which all happens in a huge dependent chain. But if you pass a URL to an asset loader and it fails to load, Phaser makes it remarkably difficult to actually figure out why it happened, or even whether it happened at all.

Not every failure to load an asset is catastrophic. But Phaser makes it hard to recover from (or even detect) such a situation. We wrote a wrapper around Phaser's asset loading that attempts to detect these errors for you, as well as provides a Promise that resolves when the asset is ready to use. You see, by default, Phaser's assets are ready to use "eventually" with no way of knowing when it's OK to start using them. Phaser's recommendation? Well, any assets you preload are guaranteed* to be ready** by the time your scene starts. So just preload every asset you "might" use. 🙄

The situation isn't entirely impossible. Thankfully, the Phaser asset loader has a couple hooks you can listen for to try and detect when a specific asset is loaded or fails. However, the behavior of the hooks is wildly inconsistent depending on what type of asset you're loading. Let's take a quick look at how it works, and why it's so annoying to write an asset loader for Phaser that doesn't suck.

So, you want to load some different types of assets, and you want to handle the case where they fail to load for any reason. At a high level, here's what you need to do to safely load an asset:

  1. First, check to make sure this asset isn't already loaded. Phaser doesn't provide a good way of checking this, so you'll need to keep track yourself somehow.
  2. Next, check to see if the Phaser loader exists. It might not if a scene hasn't been started yet.
  3. Then, if it exists, check to see if the asset is currently loading. Phaser doesn't correctly handle loading the same asset more than once, so you need to prevent this from happening at all costs.
  4. Once you've confirmed Phaser has never seen this asset before, create a new promise and add it to a global store somewhere. Make sure to keep track of what kind of asset you're loading, because that matters later.
  5. Then, check to see if the Phaser loader is started. If it's not, you need to manually start it. It automatically stops again when all assets are done loading so this check has to happen every time.
  6. Then, in the Phaser loader, hook these two events: FILE_COMPLETE and FILE_LOAD_ERROR. Don't be fooled by COMPLETE, FILE_KEY_COMPLETE, or FILE_LOAD. None of those do the right thing.
  7. If you get a FILE_LOAD_ERROR, look the file key up in your global promise table, and reject the corresponding promise. Don't worry about logging this, because Phaser will log it to the console whether you want it to or not.
  8. Otherwise, if you get a FILE_COMPLETE, check what kind of asset it is. If it's an image, you're done. If it's a font, you're not done. You need keep track of the fact that you've seen one FILE_COMPLETE for it so far. Some asset types trigger it once, some two, and some three. For some it depends, too. You'll need custom handling for all possible cases.
  9. Once you've seen enough FILE_COMPLETE events, you can finally resolve the promise and clear it from the global store.

And that's it! You've now written an asset loader that can handle assets not loading, and tell you when it's safe to start using them. And it only took about 150 lines of code! This gets exponentially more complicated if you want to do stuff before the asset is done loading though. In our engine, we have the ability to spawn an object with a sprite that wasn't loaded yet, and use it as normal, rendering it with a placeholder in the meantime. Then the sprite will "pop in" as soon as it's loaded. Phaser provides absolutely no support for this kind of thing, so we have a custom (and admittedly janky) system for it. It feels like this should be a common thing to want to do, though.

Like many web packages, Phaser handles the easy part badly and then leaves the hard parts as an exercise for the reader.

I'm not surprised that Phaser has evolved in this way. Taking one look at their demos page will explain everything. They have a ridiculous number of demos, but none of them are cohesive. Almost all of them are under 50 lines of code, and are there to showcase how "easy" it is to do specific thing A or B. But to make a functioning game, you can't just show A, B, and C in separate demos. You need everything working together. And that's where all of this falls apart. It's incredibly telling that they made separate demos for "16 camera test", "32 camera test", "16 camera shader test", "add camera on click", "camera fade in and out", "camera fade out and in", "fade", and "multiple cameras". Would it have really been so hard to make a single scene that demonstrates all of these things? Phaser has a deeply ingrained attitude of worrying about having as many features as possible, and then not giving any thought at all to whether those features are actually usable on a real game.

4) Phaser's community doesn't know better

Several people I spoke to in the web space talked up the Phaser community a lot. I was told they're incredibly helpful, super responsive, and super friendly. I was told they can help you get problems fixed really fast. I think that would be my experience if I knew nothing about game (or even web) development, but as someone with at least a few shreds of competence, and a couple years of prior game dev experience, I ended up forging my own path after a while. It was literally just faster to write a new API than it was to work through why Phaser seemed broken with the zero to two people in the community who actually understand how any given piece of Phaser works. Phaser's community might be helpful, but they're also pretty lost when it comes to anything greater than a toy project you crank out in a weekend. You'll learn more from a debugger pointed at Phaser than from anyone in the community I met.

I can't say I fully blame them for that. Phaser has a lot of beginners and they're probably not accustomed to getting asks from people who know what they're doing already. Phaser has a lot of users, but almost none of them are building anything more complicated than Doodle Jump. I get the impression that most of their docs and demos were made to ease the support burden on themselves, rather than actually teach you anything.

The long-term consequences of this strategy are insidious. Because the docs are so bad and the demos so narrowly-scoped, nobody ever learns a "good" way of using Phaser. Just a way that works. The engine co-evolves with this mindset. More and more features get bolted on with limited testing and no maintenance plan. I fully expect Phaser to get worse. They suffer from a big community problem and it's one entirely of their own making. So what are they doing about it?

5) Phaser intends to profit from their mistakes

Phaser 3 has so many holes in its API that it looks like a screen door. Phaser 4 has been long-promised to "fix everything". It's been a year away for 5 years. The dev team has finally been spurred to action by a heavy dose of VC funding earlier this year, which is always a good sign for an open source project. While there have been no updates on Phaser 4 whatsoever, there have been immediate updates to their plans for pricing. They've already started charging for an editor which offers an interface nothing like what the Phaser API gives you. Why, it almost looks reasonable! Funny how that works.

Notice what features they advertise for developers. For $108/year, you get the editor, 25 cents worth of cloud storage, and... tutorials, demos, and tech support. You're telling me the people who divided their docs into "namespaces", "classes", and "events" are now asking me to pay them for documentation? This is fucked up.

Also worth noting that they're including a bunch of "planned" features, with the only indication they're unavailable being a little asterisk next to them. That's not misleading at all.

Phaser's website, some features randomly have a small '*' next to them

Even if you think I've been unfair to Phaser this entire time, you have to admit this is pretty slimy. Most of these features don't exist yet, but don't worry. You can start paying today.

At $108/year (per seat) the "Developer license" is a questionable value but is perhaps justifiable. If the editor is really good, it might be worth it. Where they really get shitty in my eyes is the "Professional" license. It's an extra $372/year ($480 total) for $2/month worth of cloud storage, "Job alerts", and 30 minutes of live monthly tech support via Discord. And of course a bunch of "playable ad templates". I don't understand who this license is for. Who has a game that's good enough to advertise, but still needs job alerts and tech support? These paid plans are somehow even less thought-out than the Phaser API.

If you don't see the problem with Phaser's business model, let me tell you a story. Me and my friend were both TAs at the same time in college. On starting our jobs, we both quickly ran into the same problem: Teaching people is frustrating. Learning is hard, and a lot of people are conditioned to actively avoid anything hard, desperately searching for ways to make it easy. I developed a strategy to deal with this. I would avoid answering questions they could figure out themselves, and instead tried to help them figure out an answer on their own. Most of the time, the problem was that they were hung up on something unimportant, or simply needed a bit more practice with a topic. If I sensed a true disconnect somewhere, I would walk them over to a whiteboard and show them how to work through a similar problem. But I would never just give them the answer.

My friend, on the other hand, would. He would look at their code, and point out bugs. At some points he even wrote a little example code for them. He quickly became an extremely popular TA, because if you met with him, you would always leave the meeting with a finished assignment. But he created a problem for himself. He was addicted to the popularity. Students were making memes about him and how awesome he was. This encouraged him to keep giving out answers and keep making things easy. The students he was TAing for also didn't really get better throughout the semester. If anything, they got worse. Once they were on the treadmill they never really seemed to get off. By the end of the semester he was considered a pillar of the class, with literally every student showing up to his office hours. But in the process, he also robbed them of valuable debugging skills, and the discipline to work through difficult problems.

I don't like Phaser. I don't like it on a technical level. I don't like it on a philosophical level. And at this point I don't even like it on a moral level. I don't like the way they treat the community, even though their conduct is admirable. Phaser is the popular TA who will help you get through assignments, but as soon as you stray outside the boundaries of the classroom you find yourself lost. You'll think back on it, years later, wishing you instead had a mentor who encouraged you to think more. Meanwhile they're making jokes about how incompetent you are behind your back while cashing in that sweet student employee paycheck.

I don't like Phaser.