Are you looking for a clean and reusable way to implement undo for player actions?
Perhaps you are making a turn-based strategy game or a puzzle game where the player can test what an action might look like before confirming it?
Then the Command Pattern is what you are looking for. 👀
You may have heard of this pattern because it is commonly used for undo/redo and in this article, we will look at using it in an existing push-box or Sokoban puzzle game.
We are using an existing game to better demonstrate what a realistic use of this pattern would look like. Many patterns are easy to implement at the beginning of a project but it is more likely that you are in the middle of a project.
Sometimes, you only realize you need a pattern or learn of it after you've already coded a bunch of gameplay. Redoing everything from scratch is a non-starter.
It is also important to learn the art of refactoring when doing work in the real world. 😎
Sokoban Template
We will be using the Sokoban Template that you can find here.
The template uses Phaser 3 and is written in TypeScript.
This is a more advanced article so we recommend going through the Sokoban video tutorial series if you are not familiar with TypeScript.
We'll be using language features like interfaces and abstract classes in this article to implement the Command Pattern.
TypeScript and Phaser 3 are a great combination for making games on the web. You can get our free ebook to learn more if you are just getting started 👇
Learn to make an Infinite Runner in Phaser 3 with TypeScript!
Drop your email into the box below to get this free 90+ page book and join our newsletter.
Learn more about the book here.
Commanding Moves
The Command Pattern is a method for encapsulating a request or action into something that can be passed around, reused, or undone. Each request or action is a command.
Our Sokoban game has a player that can move left, right, up, and down while pushing any boxes that are in the way.
We can look at these move actions as commands.
The template does not include undo functionality that is common to Sokoban games but it can be easily accomplished using the Command Pattern.
To start, let's look at the basic move logic in the update()
method found in the Game
Scene:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
update()
{
// ...
const justLeft = Phaser.Input.Keyboard.JustDown(this.cursors.left!)
const justRight = Phaser.Input.Keyboard.JustDown(this.cursors.right!)
const justDown = Phaser.Input.Keyboard.JustDown(this.cursors.down!)
const justUp = Phaser.Input.Keyboard.JustDown(this.cursors.up!)
if (justLeft)
{
this.tweenMove(Direction.Left, () => {
this.player?.anims.play('left', true)
})
}
else if (justRight)
{
this.tweenMove(Direction.Right, () => {
this.player?.anims.play('right', true)
})
}
else if (justUp)
{
this.tweenMove(Direction.Up, () => {
this.player?.anims.play('up', true)
})
}
else if (justDown)
{
this.tweenMove(Direction.Down, () => {
this.player?.anims.play('down', true)
})
}
}
|
The code is fairly straight-forward: pressing the arrow keys will move the player in the desired direction.
Let's start applying the Command Pattern by taking the move left logic and putting it into a separate class called MoveLeftCommand
. This means we'll have to take the tweenMove()
method and anything it depends on.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// 👇 varous imports needed by tweenMove()
import { offsetForDirection } from '../../utils/TileUtils'
import { baseTweenForDirection } from '../../utils/TweenUtils'
import { Direction } from '../../consts/Direction'
import {
boxColorToTargeColor
} from '../../utils/ColorUtils'
export default class MoveLeftCommand
{
// copied from Game Scene
private tweenMove(direction: Direction, onStart: () => void)
{
// 🚨 many errors in this method
}
}
|
We omitted the code in tweenMove()
because we'll need to update it. You should notice that there are many errors as the code is referencing methods from the Game
Scene.
First, the easy things to fix are the references to this.player
, this.tweens
, and this.sound
. We just need to take a reference to the player Sprite and the Scene in the constructor:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
export default class MoveLeftCommand
{
private scene: Phaser.Scene
private player: Phaser.GameObjects.Sprite
constructor(scene: Phaser.Scene, player: Phaser.GameObjects.Sprite)
{
this.scene = scene
this.player = player
}
// ...
}
|
Now, we can change calls to this.tweens
and this.sound
to this.scene.tweens
and this.scene.sound
.
Next item to tackle is the calls to Game
Scene methods like hasWallAt()
, getBoxDataAt()
, and others.
The simplest thing to do is to simply say that the passed in scene
from the constructor is of type Game
and then make the referenced methods public
instead of private
.
That would work fine but we are going to go in a different direction.
The ILevelState Interface
When working with large or complex codebases, programming against interfaces will lead to easier to maintain code than programming against implementations.
What this means in our example is that we'll create an ILevelState
interface with the various methods from the Game
Scene that we want to call such as hasWallAt()
, getBoxDataAt()
, etc.
Then we'll take an instance of ILevelState
in the constructor and use it to fix the remaining errors in tweenMove()
.
Here's what ILeveState
looks like 👇
1
2
3
4
5
6
7
8
|
// in a file named `ILevelState.ts`
export default interface ILevelState
{
getBoxDataAt(x: number, y: number): { box: Phaser.GameObjects.Sprite, color: number } | undefined
hasWallAt(x: number, y: number): boolean
changeTargetCoveredCountForColor(color: number, change: number): void
hasTargetAt(x: number, y: number, tileIndex: number): boolean
}
|
Next, update MoveLeftCommand
like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
export default class MoveLeftCommand
{
private scene: Phaser.Scene
private player: Phaser.GameObjects.Sprite
private levelState: ILevelState
constructor(scene: Phaser.Scene, player: Phaser.GameObjects.Sprite, levelState: ILevelState)
{
this.scene = scene
this.player = player
this.levelState = levelState
}
// ...
}
|
Then the tweenMove()
method can replace calls to this.hasWallAt()
and similar with this.levelState.hasWallAt()
. This should fix all but 1 error:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
|
private tweenMove(direction: Direction, onStart: () => void)
{
if (!this.player || this.scene.tweens.isTweening(this.player!))
{
return
}
const x = this.player.x
const y = this.player.y
const offset = offsetForDirection(direction)
const ox = x + offset.x
const oy = y + offset.y
const hasWall = this.levelState.hasWallAt(ox, oy)
if (hasWall)
{
this.scene.sound.play('error')
return
}
const baseTween = baseTweenForDirection(direction)
const boxData = this.levelState.getBoxDataAt(ox, oy)
if (boxData)
{
const nextOffset = offsetForDirection(direction, 2)
const nx = x + nextOffset.x
const ny = y + nextOffset.y
const nextBoxData = this.levelState.getBoxDataAt(nx, ny)
if (nextBoxData)
{
this.scene.sound.play('error')
return
}
if (this.levelState.hasWallAt(nx, ny))
{
this.scene.sound.play('error')
return
}
const box = boxData.box
const boxColor = boxData.color
const targetColor = boxColorToTargeColor(boxColor)
const coveredTarget = this.levelState.hasTargetAt(box.x, box.y, targetColor)
if (coveredTarget)
{
this.levelState.changeTargetCoveredCountForColor(targetColor, -1)
}
this.scene.sound.play('move')
this.scene.tweens.add(Object.assign(
baseTween,
{
targets: box,
onComplete: () => {
const coveredTarget = this.levelState.hasTargetAt(box.x, box.y, targetColor)
if (coveredTarget)
{
this.levelState.changeTargetCoveredCountForColor(targetColor, 1)
}
}
}
))
}
this.tweens.add(Object.assign(
baseTween,
{
targets: this.player,
onComplete: this.handlePlayerStopped, // 🚨
onCompleteScope: this,
onStart
}
))
}
|
The handlePlayerStopped()
method on the Game
Scene sets the player's animation to idle, updates the moves count, and checks if the level is completed.
Our command should only set the player's animation to idle as that is part of moving and then provide a callback to let the Game
Scene handle the rest.
To do that we can add a stopPlayerAnimation()
method:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
private stopPlayerAnimation()
{
if (!this.player)
{
return
}
const key = this.player?.anims.currentAnim?.key
if (!key.startsWith('idle-'))
{
this.player.anims.play(`idle-${key}`, true)
}
}
|
Then, update the tweenMove()
method to take an onComplete
callback and update the tween to use it like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
private tweenMove(direction: Direction, onStart: () => void, onComplete?: () => void)
{
// other code...
this.scene.tweens.add(Object.assign(
baseTween,
{
targets: this.player,
onComplete: () => {
this.stopPlayerAnimation()
if (onComplete)
{
onComplete()
}
},
onStart
}
))
}
|
The MoveLeftCommand
class should now be error-free. 🎉
Execute and Undo
Our MoveLeftCommand
needs 2 more methods for executing an action and then undoing it.
Add these two execute()
and undo()
methods to the class like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
export default class MoveLeftCommand
{
// ...
execute(onComplete?: () => void)
{
this.tweenMove(Direction.Left, () => {
this.player.anims.play('left', true)
}, onComplete)
}
undo(onComplete?: () => void)
{
this.tweenMove(Direction.Right, () => {
this.player.anims.play('right', true)
}, onComplete)
}
}
|
Notice that execute()
simply calls tweenMove()
with Direction.Left
and then undo()
does the same thing except it uses the opposite direction.
There's also an optional onComplete
handler for any logic that needs to happen after the move is finished.
Using MoveLeftCommand
There's a fair amount of code in MoveLeftCommand
so let's make sure it does what we expect.
First, update the Game
Scene to implement the ILevelState
interface like this:
1
2
3
4
5
6
7
8
|
// other imports...
import ILevelState from './ILevelState'
export default class Game extends Phaser.Scene implements ILevelState
{
// ...
}
|
Then change getBoxDataAt()
, hasWallAt()
, changeTargetCoveredCountForColor()
, and hasTargetAt()
from private
to public
by simply removing the private
keyword.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
export default class Game extends Phaser.Scene implements ILevelState
{
// ...
// 👇 notice the private keyword is removed
changeTargetCoveredCountForColor(color: number, change: number)
{
// ...
}
getBoxDataAt(x: number, y: number)
{
// ...
}
hasWallAt(x: number, y: number)
{
// ...
}
hasTargetAt(x: number, y: number, tileIndex: number)
{
// ...
}
}
|
Next, replace the move left code from the update()
method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// 1️⃣ import this 👇
import MoveLeftCommand from './moves/MoveLeftCommand'
// 2️⃣ then in the update() method
update()
{
// ...
const justLeft = Phaser.Input.Keyboard.JustDown(this.cursors.left!)
// ...
if (justLeft)
{
const command = new MoveLeftCommand(this, this.player!, this)
command.execute(() => this.handlePlayerStopped())
}
// ...
}
|
Play the game and everything should still work as expected. Pressing the left arrow key should move the player and any boxes in the way.
Generalizing Move Commands
Think about what a MoveRightCommand
would look like and you should see that it will share a lot of code with MoveLeftCommand
.
The only thing that will be different is the direction given to tweenMove()
in the execute()
and undo()
methods.
This means we can extract the common logic into an abstract base class called BaseMoveCommand
that LeftMoveCommand
, RightMoveCommand
, and others can extend from.
An abstract
class is a class that cannot be used to create new instances. Instead, it must be subclassed. It is similar to the idea of an interface except it contains concrete implementations.
This is what BaseMoveCommand
would look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
|
import IMoveCommand from './IMoveCommand'
import ILevelState from '../ILevelState'
import { offsetForDirection } from '../../utils/TileUtils'
import { baseTweenForDirection } from '../../utils/TweenUtils'
import { Direction } from '../../consts/Direction'
import {
boxColorToTargeColor
} from '../../utils/ColorUtils'
export default abstract class BaseMoveCommand
{
protected scene: Phaser.Scene
protected player: Phaser.GameObjects.Sprite
private levelState: ILevelState
protected pushedBoxData?: { box: Phaser.GameObjects.Sprite, color: number }
constructor(scene: Phaser.Scene, player: Phaser.GameObjects.Sprite, levelState: ILevelState)
{
this.scene = scene
this.player = player
this.levelState = levelState
}
abstract execute(onComplete?: () => void)
abstract undo(onComplete?: () => void)
protected tweenMove(direction: Direction, onStart: () => void, onComplete?: () => void)
{
if (!this.player || this.scene.tweens.isTweening(this.player!))
{
return
}
const x = this.player.x
const y = this.player.y
const offset = offsetForDirection(direction)
const ox = x + offset.x
const oy = y + offset.y
const hasWall = this.levelState.hasWallAt(ox, oy)
if (hasWall)
{
this.scene.sound.play('error')
return
}
const baseTween = baseTweenForDirection(direction)
const boxData = this.levelState.getBoxDataAt(ox, oy)
if (boxData)
{
const nextOffset = offsetForDirection(direction, 2)
const nx = x + nextOffset.x
const ny = y + nextOffset.y
const nextBoxData = this.levelState.getBoxDataAt(nx, ny)
if (nextBoxData)
{
this.scene.sound.play('error')
return
}
if (this.levelState.hasWallAt(nx, ny))
{
this.scene.sound.play('error')
return
}
// store the box data that will be pushed
this.pushedBoxData = boxData
const box = boxData.box
const boxColor = boxData.color
const targetColor = boxColorToTargeColor(boxColor)
const coveredTarget = this.levelState.hasTargetAt(box.x, box.y, targetColor)
if (coveredTarget)
{
this.levelState.changeTargetCoveredCountForColor(targetColor, -1)
}
this.scene.sound.play('move')
this.scene.tweens.add(Object.assign(
baseTween,
{
targets: box,
onComplete: () => {
const coveredTarget = this.levelState.hasTargetAt(box.x, box.y, targetColor)
if (coveredTarget)
{
this.levelState.changeTargetCoveredCountForColor(targetColor, 1)
}
}
}
))
}
this.scene.tweens.add(Object.assign(
baseTween,
{
targets: this.player,
onComplete: () => {
this.stopPlayerAnimation()
if (onComplete)
{
onComplete()
}
},
onStart
}
))
}
private stopPlayerAnimation()
{
if (!this.player)
{
return
}
const key = this.player?.anims.currentAnim?.key
if (!key.startsWith('idle-'))
{
this.player.anims.play(`idle-${key}`, true)
}
}
}
|
The code here is largely the same as MoveLeftCommand
except we've removed the implementation for execute()
and undo()
and made them abstract
. This means each subclass will have to provide an implementation for these 2 methods.
Then we can update MoveLeftCommand
to extend from BaseMoveCommand
and remove 90% of the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { Direction } from '../../consts/Direction'
import BaseMoveCommand from './BaseMoveCommand'
export default class MoveLeftCommand extends BaseMoveCommand
{
execute(onComplete: () => void)
{
this.tweenMove(Direction.Left, () => {
this.player.anims.play('left', true)
}, onComplete)
}
undo(onComplete?: () => void)
{
this.tweenMove(Direction.Right, () => {
this.player.anims.play('right', true)
}, onComplete)
}
}
|
Implementing MoveRightCommand
, MoveUpCommand
, and MoveDownCommand
is something we'll leave to you. It should be pretty simple using MoveLeftCommand
above as an example.
Implementing Undo
We've done a lot of work to refactor existing code into something that will allow us to easily add undo.
What we'll need to do is save each executed command to a stack or array and then call undo()
on the last command each time we want to undo an action.
First, add a moves
property to the Game
Scene:
1
2
3
4
5
6
7
8
9
10
|
// other imports...
import BaseMoveCommand from './moves/BaseMoveCommand'
export default class Game extends Phaser.Scene implements ILevelState
{
// ...
private moves: BaseMoveCommand[] = []
}
|
Let's make a simple undo button in the Game
Scene's create()
method like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
create(d: { level: number })
{
// other code...
// undo button
this.add.rectangle(580, 50, 100, 30, 0x0000ff)
.setInteractive()
.on(Phaser.Input.Events.GAMEOBJECT_POINTER_UP, () => {
const command = this.moves.pop()
if (command)
{
command.undo(() => {
--this.movesCount
this.updateMovesCount()
})
}
})
this.add.text(580, 50, 'UNDO', { color: '#ffffff'}).setOrigin(0.5)
}
|
Then, update the code in update()
to add the executed command to the moves
list like this:
1
2
3
4
5
6
7
8
9
10
11
12
|
update()
{
// ...
if (justLeft)
{
const command = new MoveLeftCommand(this, this.player!, this)
command.execute(() => this.handlePlayerStopped())
this.moves.push(command)
}
// ...
}
|
Try this out and you'll see undo working for the player's movement. One problem you'll notice is that any pushed box does not move back. 😭
We can fix this by keeping track of any pushed boxes in the BaseMoveCommand
by adding a pushedBoxData
property and setting it to the moved box in tweenMove()
.
1
2
3
4
5
6
7
8
9
10
11
12
|
export default abstract class BaseMoveCommand implements IMoveCommand
{
protected pushedBoxData?: { box: Phaser.GameObjects.Sprite, color: number }
protected tweenMove(direction: Direction, onStart: () => void, onComplete?: () => void)
{
// other code...
// store the box data that will be pushed
this.pushedBoxData = boxData
}
}
|
Then, we can implement a moveBox()
method to move the pushed box in the appropriate direction.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
protected moveBox(direction: Direction, boxData?: { box: Phaser.GameObjects.Sprite, color: number })
{
if (!boxData)
{
return
}
const baseTween = baseTweenForDirection(direction)
const { box, color } = boxData
this.scene.tweens.add(Object.assign(
baseTween,
{
targets: box,
onComplete: () => {
const coveredTarget = this.levelState.hasTargetAt(box.x, box.y, color)
if (coveredTarget)
{
this.levelState.changeTargetCoveredCountForColor(color, 1)
}
}
}
))
}
|
Final step is to call this.moveBox()
in the undo()
method for each move command like this:
1
2
3
4
5
6
7
8
9
10
|
// in MoveRightCommand for example
undo(onComplete?: () => void)
{
this.tweenMove(Direction.Left, () => {
this.player.anims.play('left', true)
}, onComplete)
this.moveBox(Direction.Left, this.pushedBoxData)
}
|
Now, undo will move the player and any pushed boxes! 👍
Next Steps
This was quite a long article with many moving pieces so take some time to digest it. 🤓
The Command Pattern is conceptually simple but quite versatile. Along with undoing actions, it can be used to retry actions, delay actions, chain actions together, and more!
For more code refactoring and clean-up, you can try creating a dedicated class that implements ILevelState
instead of keeping that logic in the Game
Scene and deleting logic that was moved to the BaseMoveCommand
.
If you want more strategies for code organization and best practices when making games with Phaser 3 then check out the Memory Match Extras video course.
Not ready for a course? Then be sure to subscribe to our newsletter for more game development tips and techniques! 👇
Don't miss any future game development content
Subscribers get exclusive tips & techniques not found on the blog!
Join our newsletter. It's free. We don't spam. Spamming is for jerks.