Handling Inputs in Phaser 3: Part 1: Humble Beginnings
This series is going to be a walk through how I iterated on my input code over working on a game that I made, on and off (mostly off), over a couple years. Hopefully the progression of code will be somewhat interesting, and I like to think that I arrived at a rather robust input solution. If you’re only interested in the solution that I got to, you can skip this post.
A First Approach
When I was first working on my block-dropping game, Four Block Drop, my primary goal was to get working keyboard support. The basic stuff—like moving blocks left, right, and down. Phaser has multiple ways to get keyboard input:
There’s this.input.keyboard.addKey(someKey)
, which returns a reference to a key that you can poll.1
Additionally, there’s this.input.keyboard.on('keydown', function (event) { handleKey()})
, which allows you to get a callback when a key is pressed.
While I extensively use callbacks (the UI in Four Block Drop is updated via callbacks by my game logic, which isn’t attached to the rendering at all), I’ve never been a fan of callbacks for input. For one, I’m unsure where in the game loop lifecycle an input would occur.2
The conditions under which Four Block Drop accepts inputs nudged me towards polling as well. Inputs aren’t smooth (a block either drops a frame or it doesn’t), and they repeat at intervals. Even if I did adopt a callback approach, I’d most likely have to use polling as well.
My first iteration looked like the following3:
left: Phaser.Input.Keyboard.Key;
right: Phaser.Input.Keyboard.Key;
up: Phaser.Input.Keyboard.Key;
create(): void {
this.left =
this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT);
this.right =
this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT);
this.up =
this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.UP);
}
update(now: number, delta: number): void {
if (Phaser.Input.Keyboard.JustDown(this.left)) {
// do left code
}
if (Phaser.Input.Keyboard.JustDown(this.right)) {
// do right code
}
if (Phaser.Input.Keyboard.JustDown(this.up)) {
// do up code
}
}
Is it elegant? No. Does it work? Yes.4 The update code is still pretty close to what I have in the final version. It’s going to come down to reading the inputs and acting upon them at some point, after all. Off I went, content with this approach while I whittled away at the meat of the game.
Coming Back for More
Soon, however, I found myself wanting to be able to use more keys for the left, right, up, and down actions. The numpad is great, so why not support that? On its face, it’s a fairly trivial change. Create the keys as above, but create extra key objects for the alternate keys. Then, when you’re polling for inputs, check if either of them have just been pressed.
As long as you’re only doing a relatively small amount of keys per input, you’re only repeating yourself three times (declaration, assignment, and usage). It’s not ideal, but it works. It also pollutes the class variable namespace a bit, but that’s a preference issue.
Regardless, I wasn’t happy with this approach. Having to tweak this in three different places every time was a pain, I wanted up to five inputs per key, and over-engineering is fun.
The change for this is pretty straight-forward. We want to only declare the variables in one place, so we can pass them in to the constructor for a class that looks something like this:
class FBDInputHandler {
// The any in the dictionary signature is because
// Phaser.Input.Keyboard.KeyCodes is a namespace and cannot be assigned as
// a type (as far as I know).
constructor(scene: Phaser.Scene, inputMappings: { [key: string]: any }) {
this.mappings = {};
for (const key in inputMappings) {
let vals = [];
for (let i = 0; i < inputMappings[key].length; i++) {
vals.push(scene.input.keyboard.addKey(inputMappings[key][i]));
}
this.mappings[key] = vals;
}
}
isJustDown(key): boolean {
for (let i = 0; i < this.mappings[key].length; i++) {
if (Phaser.Input.Keyboard.JustDown(this.mappings[key][i]))
return true;
}
return false;
}
isDown(key): boolean {
for (let i = 0; i < this.mappings[key].length; i++) {
if (this.mappings[key][i].isDown) return true;
}
return false;
}
}
Then, we could invoke the code like this:
inputHandler: FBDInputHandler;
create(): void {
this.inputHandler = new FBDInputHandler(this, {
'left': [
Phaser.Input.Keyboard.KeyCodes.LEFT,
Phaser.Input.Keyboard.KeyCodes.NUMPAD_FOUR,
],
'right': [
Phaser.Input.Keyboard.KeyCodes.RIGHT,
Phaser.Input.Keyboard.KeyCodes.NUMPAD_SIX,
],
'up': [
Phaser.Input.Keyboard.KeyCodes.UP,
Phaser.Input.Keyboard.KeyCodes.NUMPAD_EIGHT,
],
});
}
update(now: number, delta: number): void {
if (this.inputHandler.isJustDown('left')) {
// do left code
}
if (this.inputHandler.isJustDown('right')) {
// do right code
}
if (this.inputHandler.isJustDown('up')) {
// do up code
}
}
We’ve just made a slightly less efficient,5 way more verbose,6 just as buggy,7 and much more readable and maintainable8 version of doing the first approach. This was all well and good, but it led me to my next issue—I wanted to do more than just check whether a key was pressed on a given frame.
Specifically, I wanted to have the left and right inputs do two things:
- Repeat: Repeat the block movement if the key was held down for a given amount of time.
- Compete: Only handle one of the left and right movements at a time—the one that was pressed later or, if both were pressed on the same frame, the right one. If both are pressed and one is released, move in the other direction.
These were beyond the scope of my simple input handler class, and it didn’t make sense to put more complexity into FBDInputHandler. After all, how could I robustly define which inputs would compete with each other without making the construction and internals of the class a mess? It certainly didn’t seem worth the complexity.9
Competing for Inputs
My solution was to move all of this ugliness and additional complexity into a new class, which I named FBDDirectionalComputer. I banged away at it for a while, and arrived quickly at the following code10 (this is a lengthy class to inline, feel free to skip or skim):
class FBDDirectionalComputer {
leftDown: boolean;
rightDown: boolean;
lastPressWasLeft: boolean;
repeatDelayTimer: FBDGameTimer;
repeatTimer: FBDGameTimer;
repeatDelayTime: number;
repeatTime: number;
constructor(repeatDelayTime: number, repeatTime: number) {
this.leftDown = false;
this.rightDown = false;
this.lastPressWasLeft = true;
this.repeatDelayTimer = null;
this.repeatTimer = null;
this.pressLeft = false;
this.pressRight = false;
this.repeatTime = repeatTime;
this.repeatDelayTime = repeatDelayTime;
}
update(now: number, isLeftJustDown: boolean, isLeftDown: boolean,
isRightJustDown: boolean, isRightDown: boolean) {
let newPress = false;
this.pressLeft = false;
this.pressRight = false;
if (!isLeftDown && !isRightDown) {
this.leftDown = false;
this.rightDown = false;
this.repeatDelayTimer = null;
this.repeatTimer = null;
return;
}
if (isLeftJustDown) {
this.lastPressWasLeft = true;
newPress = true;
}
// Having this after isLeftJustDown means that this will have higher
// precedence over a left input, which is intentional.
if (isRightJustDown) {
this.lastPressWasLeft = false;
newPress = true;
}
// Both inputs were down on the last frame, and the winning input
// (right) was released, which means this input should now be treated
// as left.
if (this.rightDown && !isRightDown
&& isLeftDown && !this.lastPressWasLeft) {
this.lastPressWasLeft = true;
newPress = true;
}
// Both inputs were down on the last frame, and the winning input
// (left) was released, which means this input should now be treated
// as right. (I don't think this can happen due to the above
// precedence but I probably included it for the sake of
// completeness).
if (this.leftDown && !isLeftDown
&& isRightDown && this.lastPressWasLeft) {
this.lastPressWasLeft = false;
newPress = true;
}
// If it's a new press, reset all timers and start timer for initial
// input.
if (newPress) {
this.pressLeft = this.lastPressWasLeft;
this.pressRight = !this.lastPressWasLeft;
this.repeatDelayTimer =
new FBDGameTimer(now, this.repeatDelayTime);
this.repeatTimer = null;
}
// Else if we're waiting for the initial delay before repeating and
// it's over, set "just down" again for the relevant input and start
// the repeat timer.
else if (this.repeatDelayTimer != null
&& this.repeatDelayTimer.isOver(now)) {
this.pressLeft = this.lastPressWasLeft;
this.pressRight = !this.lastPressWasLeft;
this.repeatTimer = new FBDGameTimer(now, this.repeatTime);
this.repeatDelayTimer = null;
}
// Else if we're waiting for the subsequent delay before repeating and
// it's over, set "just down" for the relevant input and reset the
// repeat timer.
else if (this.repeatTimer != null && this.repeatTimer.isOver(now)) {
this.pressLeft = this.lastPressWasLeft;
this.pressRight = !this.lastPressWasLeft;
this.repeatTimer.reset(now);
}
this.leftDown = isLeftDown;
this.rightDown = isRightDown;
}
}
It’s a clunky class, but it does what it aims to do. Reviewing it now, there’s only two changes I would consider making:
- Removing the conditional I flagged as unnecessary.
- Genericizing the inputs to avoid a bunch of busywork if I decide to reorder the input precedence.
Altogether, not too bad for something that I probably wrote in an hour two years ago.
Unfortunately, it also makes the input reading messier11:
update(now: number, delta: number): void {
this.directionalComputer.update(
now,
this.inputHandler.isJustDown('left'),
this.inputHandler.isDown('left'),
this.inputHandler.isJustDown('right'),
this.inputHandler.isDown('right'));
if (this.directionalComputer.pressLeft) {
// do left code
}
if (this.directionalComputer.pressRight) {
// do right code
}
if (this.inputHandler.isJustDown('up')) {
// do up code
}
}
It’s ugly and a bit strange to read, but it worked for my purposes. I could have created another class that wrapped these two to make a consistent API, but the code worked and it didn’t seem like the best use of my time to shuffle around complexity.
This is how the code looked around the time that I released the first version of Four Block Drop, back in January 2019.12
In the next post, I’ll go over what happened when I decided to add controller support to my game.
-
Polling is when you read a value at a given interval. In this case, it’s every frame. ↩︎
-
If I had to guess, I’d say probably before update and preupdate, but I haven’t checked. ↩︎
-
The number and/or complexity of inputs have been simplified for the examples in this post. ↩︎
-
Note that calling Phaser.Input.Keyboard.JustDown(key) will clear Phaser’s
justDown
flag. If I wanted to read this key’s value in multiple places, this solution would not work. ↩︎ -
This doesn’t matter. If this bit of code makes the difference between your game running smoothly and not running smoothly, you have other problems. ↩︎
-
Verbosity is fine until it isn’t. When you come back in six months and struggle to understand it, you have a problem. It’s trickier to realize it at the time. I’ve since come back to this code base after a longer time, and didn’t have any issues. ↩︎
-
Both of the approaches in this post have a bug where if you pause the scene and come back to it, any keys that you were pressing as the scene changed will have to be pressed twice to trigger again once you return to the scene. This is a detail of Phaser 3’s keyboard input that I had to work around (as of 3.12.0). I solved it by having a method in FBDInputHandler that re-registered the inputs whenever returning to the scene. Alternatively, you could just recreate the FBDInputHandler object. In the upcoming posts I have a different solution (a parallel scene just for input), which has its own plusses and minuses. ↩︎
-
Hopefully. ↩︎
-
FBDInputHandler, which I elevated to KHInputHandler since I now use it across projects, remains almost as simple in its final (for now) form. Most of the additions are to prevent accidental misuse, like warning about trying to access an input with no keys attached. ↩︎
-
I omitted the code of FBDGameTimer for clarity, but it was a pause-aware timer that accounted for overages. I believe that Phaser 3’s timer functionality would serve the same purpose, but I either wasn’t aware of it or didn’t choose it because of the same concerns I had with using it for key inputs. ↩︎
-
The code in create is omitted for this example, as it’s mostly the same as the previous one, with the bonus of instantiating the FBDDirectionalComputer object. ↩︎
-
It wasn’t in typescript, but I converted these examples over for clarity. ↩︎