Enforted - Handling game state with Aurelia Store

This is the first article of the Enforted series, which is going to introduce you to how the game state is handled by using the Aurelia Store plugin. To see the other articles, hop over to the intro’s overview.

The whole world - of Enforted - in a single object

Before we dive into the topic lets have again a quick look at the games’ layout, shall we?

Overview of the game

On a quick look, we can see a lot of individual elements such as a tile, buildings, monsters and the player character, the lefthand-side overview panel as well as the central fortress. They’re all stored in individual entities and rendered using straight forward Aurelia data bindings.

Now in a classic MVVM approach, we’d start of creating one or multiple services to host that data. We could start with a BoardService to hold the tiles and buildings, a GameService to keep track of the player’s character and monsters. Last but not least a ResourceService could be the place to store information about how many resources and stats are left. And that would be a fine approach, but with a small issue. I guess you’ve noticed the sad faces, e.g at the upper left of the outer lane. Those are so-called tragedy-tiles that will introduce a random tragic event. By stepping on one of those you’ll see something like in the following screenshot.

The plague of vermins tragedy got activated

By reading the tragic message, we can infer that our mills - which are tile buildings for the food resource - are going to be set offline for the current round and won’t produce additional food resources. So once we roll our way to the next round, passing the start tile, at this moment we’d need a handle of all the three above mentioned services, so that we can appropriately adapt the new games state. That means three dependencies injected, a bunch of modified arrays/objects and code to verify that setting those new values does not override any previous action.

Now, what if instead, we could express the whole game in a single object? That might look something along these lines:

State interfaceSee the source on GitHub
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export interface State {
tiles: Tile[];
players: Player[];
monsters: Monster[];
fortressBuildings: FortressBuilding[];
tileBuildings: TileBuilding[];
lastDiceRoll?: number;
resources: Resources;
round: number;
stats: Stats;
activeFortressBuildingConstruction?: AvailableFortressBuildings;
activeTragedy?: AvailableTragedyEvents;
activeTragedyParams?: any[];
/**
* Setting this to undefined closes the purchase panel
*/
purchaseInProgress?: string;
fireFountainsActive: boolean;
gameStarted: boolean;
}

Going further, every dice roll would now create a new unique state of your game, which can be again represented with a modified state object. And if you think about it, essentially the whole game can be expressed as a series of those individual states. That is exactly where the Aurelia Store plugin comes into action.

Structuring the project

Since Enforted does not rely on any sort of backend, it’s pretty straight forward to define the initial layout by using the initial state. If you head over here, you’ll find exactly that. Essentially the player position, starting resources/stats and initial tiles get populated with their types and placement, which also allows customizing the board’s layout. In the code listing below you can see how those 72 fields are placed and initialized.

Generating tilesSee the source on GitHub
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
{
/** The boards layout
21 22 23 24 25 26 27 28 29 30 31
20 57 58 59 60 61 62 63 64 65 32
19 56 66 33
18 55 67 34
17 54 68 35
16 53 Fortress 69 36
15 52 70 37
14 51 71 38
13 50 72 39
12 49 48 47 46 45 44 43 42 41 40
11 10 09 08 07 06 05 04 03 02 01
*/
...
tiles: [
{ // Tile 01
id: guid(),
isCorner: true,
placement: "bottom",
ring: "outer",
type: "start"
},
// Tiles 02 - 10
...Array.from<Tile, Partial<Tile>>(Array(9), (_, idx) => ({
id: guid(),
isCorner: false,
placement: "bottom",
ring: "outer",
type: "wood",
})),
// Tile 11
{
id: guid(),
isCorner: true,
placement: "bottom",
ring: "outer",
type: "sacred_grounds"
},
...
]
...
}

I like to keep everything related to state management in a folder store so the state interface and initial state, therefore, are located in /src/store/state.ts. All actions are located in /src/store/actions. Now here might others say to treat actions like spec files and collocate them with the feature they’re related to. I tend to believe though that actions rarely are used in a single feature but across the app, which is why organizing them by type makes more sense for me. This is a personal preference though and in no way meant as a must.

Now that we know where they are located, let’s see how actions get registered. The start-screen component makes use of two custom actions which set the initial players position as well as let the game start. Since these actions are of primary interest for this component they get registered in this constructor.

While at it, let’s look at the start method of the component. You’ll notice the use of the stores piped dispatch feature, which essentially will pump multiple actions in one state change. The benefit with this approach is that you’ll end up with just one new state while all actions are applied at once, thus no unnecessary re-rendering is triggered. Moreover, you’ll see the au.setRoot method call which is the cheap alternative to using the full-blown Aurelia Router.

Dispatching piped actionsSee the source on GitHub
1
2
3
4
5
6
7
export class StartScreen {
...
private start(name: string) {
this.store.pipe(addPlayer, name).pipe(startGame).dispatch();
this.au.setRoot("./app");
}
}

The mother of actions: the dice roll

Amongst all those actions, rollDice is certainly one of most interest. It’s not only responsible for the player’s advancement on the board but also how monsters progress and fight, active tragedy results get applied as well as resource harvesting and stats calculations. Doing all of that in one action is pretty much overkill and asks for better separation of concerns. Knowing that actions are nothing else than pure functions, we can make use of their composability.

Let’s look at the following example quickly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface State {
hello: string,
foo: string
}

function sayHelloTo(state: State, who: string): State {
return {
...state,
hello: who
};
}

function fooIs(state: State): State {
return {
...state,
foo: "bar"
};
}

Calling the above actions individually would allow us to update the current state one by one to get the resulting:

1
2
3
4
5
6
7
8
store.dispatch(sayHelloTo, "world");
store.dispatch(fooIs);

// New state
// {
// hello: "world",
// foo: "bar"
// }

As we learned before though, that would require two states to be generated and create unnecessary intermediate steps. We could also pipe multiple actions store.pipe(sayHelloTo, "world").pipe(fooIs).dispatch() to get the same effect with one iteration. But we could very easily also create a new function which just simply makes use of the previous two.

1
2
3
function helloAndFoo(state: State, who: string): State {
return sayHelloTo(fooIs(state), who);
}

The benefit with later though is that you could also rewrite the action to make use of foos result as input for hello, which wouldn’t be possible with the piped approach.

1
2
3
4
5
6
7
8
9
function helloToFoo(state: State): State {
const whoIsFoo = fooIs(state);

return {
...state,
foo: whoIsFoo.foo, // or ...whoIsFoo
hello: sayHelloTo(whoIsFoo, whoIsFoo.foo).hello
}
}

And exactly that is what the roll dice action makes use of. Of course, we don’t need to always merge the full state in function calls but can also use individual parts of the sub-states. So the following is the result of the roll dice action. I’ll annotate every step inside the listing with comments.

diceRoll actionSee the source on GitHub
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
// start with the harvesting state of fortress buildings
return gatherFortressBuilding({
// pass it in the current state
...state,
// plus an override if the new dice roll will result in
// another round started
activeFortressBuildingConstruction: isNextRound
? undefined
: state.activeFortressBuildingConstruction,
// depending on whether it's a new round and/or we had the
// stumbling steps tragedy active (which lasts for three dice rolls)
// we set the active tragedy
activeTragedy: isNextRound
? undefined
: isStumblingStep && newStumblingSteps === undefined
? undefined
: state.activeTragedy,
// along with it's params, like how many stumblings are left
activeTragedyParams: isNextRound
? undefined
: isStumblingStep
? newStumblingSteps
: state.activeTragedyParams,
lastDiceRoll: roll,
// since handling new monsters is whole lot of a different
// complexity we separate it into it's own action and reuse
// the resulting monster prop
monsters: generateWave(monsterRollState, isNextRound).monsters,
// depending on whether we passed the last tile we accordingly
// update the players position
players: [
{
...state.players[0],
currentTileId: newPosition > state.tiles.length - 1
? state.tiles[Math.abs(newPosition - state.tiles.length)].id
: state.tiles[newPosition].id
} as Player
],
purchaseInProgress: undefined,
// calculate the new resources, by respecting harvested resources
// from existing tile buildings
resources: isNextRound
? gatherResources(state).resources
: state.resources,
round: isNextRound
? state.round + 1
: state.round,
// and update the new stats, after the result of the monster rolls
stats: monsterRollState.stats,
}, isNextRound);

With this approach, you can mix and match your resulting state, based on different sub-states, while still only emitting one single new state. That is especially important since we want to have easier means to time travel through our dice rolls using the Redux DevTools, as explained a bit later.

Persisting and rehydrating the game state

It was already mentioned that Enforted is a pure client-side game, without the need for any kind of backend. While this has advantages like being a great target for an offline PWA, it also has an immediate downside. There is no session or game state saving and persisting. To overcome this issue, we can make use of the browsers local storage. Fortunately, the Aurelia Store plugin comes with a nifty helper out of the box.

In the constructor of app.ts all the magic happens, as depicted in the following code listing. First, we’ll create a subscription after the first emitted state, to avoid the initial state propagation, and subscribe exactly for one iteration. We’ll then register the stores localStorageMiddleware and place it after the processing queue, by making use of a custom storage key. This middleware makes sure to persist the emitted state in the local storage, whenever a new action is emitted. And this is exactly the reason why we need to ditch the initial stream item as we’d otherwise immediately persist the original initial state, thus auto-negating the persisting feature.

After the action registrations are done we can safely dispatch rehydrateFromLocalStorage to load the persisted state into the store.

Using the stores localstorage action and middleware actionSee the source on GitHub
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
constructor(
private store: Store<State>,
private dialogService: DialogService,
private ea: EventAggregator
) {
this.store
.state
.pipe(
skip(1),
take(1)
).subscribe(() => {
this.store.registerMiddleware(
localStorageMiddleware,
MiddlewarePlacement.After,
{ key: LOCALSTORAGE_SAVE_KEY }
);
});

this.store.registerAction("Rehydrate from localstorage", rehydrateFromLocalStorage);
this.store.registerAction("roll the dice", rollDice);
this.store.registerAction("open the purchase panel", openPurchaseForTile);
this.store.registerAction("close the purchase panel", closePurchasePanel);
this.store.registerAction("buy a tile building", buyBuilding);
this.store.registerAction("destroy a tile building", destroyBuilding);
this.store.registerAction("[tragedy] sacrifice resources", sacrificeResources);
this.store.registerAction("[tragedy] raging fire", ragingFire);
this.store.registerAction("[tragedy] the forgotten equipment", forgottenEquipment);
this.store.registerAction("[tragedy] a defiled altar", defiledAltar);
this.store.registerAction("[tragedy] paused resource production", pausedResourceProduction);
this.store.registerAction("[tragey] collapsed mines", collapsedMines);
this.store.registerAction("[tragey] stumbling steps", stumblingSteps);
this.store.registerAction("buy fortress building", buyFortressBuilding);
this.store.registerAction("reinforce tile building", reinforceTileBuilding);

this.store.dispatch(rehydrateFromLocalStorage, LOCALSTORAGE_SAVE_KEY);
}

And that’s all that’s needed to create an auto-save behavior for the game. Now we’re not done yet here, as if you remember, the game typically starts with a start-screen first, asking for the player’s name. When we re-hydrate an already saved game state though, we’d like to ditch this and go straight away to the game board.

Remember when we said that store actions are just pure functions which you can compose? Well, we can also use the pre-made rehydrateFromLocalStorage to get hold of the state before even launching the game inside the main.ts file. Inside the function configure, we’ll grab the persisted state - note that we can pass an empty object as the state since we’re not interested in the usual state emitting behavior - and use that to decide where the root of our app is going to be.

Using the stores localstorage action and middleware actionSee the source on GitHub
1
2
const storage = rehydrateFromLocalStorage({}, LOCALSTORAGE_SAVE_KEY) as State;
aurelia.setRoot(storage.gameStarted ? "./app" : "./start-screen");

Cheating like a pro with Redux DevTools

Sometimes pictures can describe more than 1000 words, and now just imagine multiple of those in high-frame rates ;) So instead of too much talking, just take a look at the video below, to get an idea how you can use the Redux DevTools extension to undo dice rolls and go a different path. It’s a nifty feature, especially during development and a manual exploratory phase.

Conclusion

As mentioned in the intro, this is the first article of the Enforted series and I hope you enjoyed reading so far. Working with the Aurelia Store plugin is a joy and you should give it a try on your next fun project. Make sure to let me know how it worked out or any other questions you have about this article in the comments below.

Vildan Softic

Vildan Softic is a consultant and software developer from Austria. He is passionate about developing Single Page Applications, grinding LOB Apps with .NET and is pushing towards Node.JS development.