We’re learning about Redux.js under the guise of making a mock RPG-style game.
In Part 2 we looked at reducing boilerplate code using redux-actions. We also pointed out a dependency problem where the hero's current level depends on the hero's experience points.
In this part we'll put a little more work into formalizing experience points and leveling up, and I'll show you how to handle dependencies within your state model.
Suppose we defined some levels like this...
const levels = [ { xp: 0, maxHealth: 50 }, // Level 1 { xp: 100, maxHealth: 55 }, // Level 2 { xp: 250, maxHealth: 60 }, // Level 3 { xp: 500, maxHealth: 67 }, // Level 4 { xp: 1000, maxHealth: 75 }, // Level 5];
When the user gains some experience:
store.dispatch(gainXp(100));
Our program will increase the XP but it currently has no way to trigger a level up. Let's look at some ways to fix the code.
Keep It Simple
Before refactoring our reducers at the end of Part 2, we had a separate reducer for level and XP. The XP reducer only had access to the XP value and it had no way to update the level.
Sometimes it's possible to restructure your state model and reducers to eliminate dependencies like our XP-and-level example.
With xpReducer and levelReducer combined into the heroReducer, we have access to the XP and level at the same time. We could even decide to get rid of the LEVEL_UP action entirely and just handle both things at once.
switch (action.type) { case Actions.GAIN_XP: // Get the current XP and Level let { xp, level } = state; // Add the XP we just gained xp += action.payload; // Level up if we have enough XP if (xp >= levels[level].xp) { level++; } return { ...state, xp, level };
Most games will give your character full health upon leveling up. I think we should do that too. But if we eliminate our LEVEL_UP action, then we'd need to adjust the hero's health in the heroReducer. This violates encapsulation and the Single-Responsibility Principle because we'd have to reach down into the stats object. Alternately, we could handle GAIN_XP in statsReducer. But then we'd have to duplicate the code that checks for leveling up. That's a lose/lose situation.
We could also just move all the stats directly onto the hero state structure. That would make a lot of sense and would be in line with the keeping-it-simple principle... but I'm trying to make a point. So just play along for now.
Calculate Derived Values
We've already got a fixed array of levels that tell us how much XP is required for each level and what the maxHealth of each level is. There's no good reason to duplicate this in our state model.
Before we begin, we'll create some basic selectors to give us current values from a state object.
// Given the game state, select the hero's experience pointsconst getXp = state => state.hero.xp;// Given the game state, select the hero's current healthconst getHealth = state => state.hero.stats.health;
We can compute the level based on the current XP:
// Given the game state, select the current level// (count the number of levels that we have more XP than)const getLevel = state => levels.filter(level => getXp(state) >= level.xp ).length;
And we can compute the maxHealth based on the current level.
// Given the game state, select the max healthconst getMaxHealth = state => levels[getLevel(state)].maxHealth;
We could take this dependency chain even further by calculating whether the hero's health is low enough that we'd want to give the player some visual feedback... something like a pulsing red heart.
const isHealthLow = state => getHealth(state) < getMaxHealth(state) * .15;
Optimization
The problem with the selectors we created is that they are recomputed on every invocation. We're not exactly crushing the CPU here... but in a real app you may have selectors that take a non-trivial amount of computation.
What if the selectors could be written to only recompute when the dependencies change?
There's a cool library called reselect that does this for us.
Reselect provides a method, createSelector, which takes an existing selector (or array of selectors) and a function to evaluate. The function is only evaluated when the input selectors change. The technique is called memoization.
Look at the selectors rewritten to use the createSelector method from reselect
// in a real app, use// import { createSelector } from 'reselect';const { createSelector } = Reselect;// ...const getXp = state => state.hero.xp;const getHealth = state => state.hero.stats.health;const getLevel = createSelector( getXp, xp => levels.filter(level => xp >= level.xp ).length );const getMaxHealth = createSelector( getLevel, l => levels[l].maxHealth );const isHealthLow = createSelector( [ getHealth, getMaxHealth ], (health, maxHealth) => health < maxHealth * .15 );
The view layer might call getLevel on every frame but it only calculates the value each time the hero's XP changes. The isHealthLow function has two dependencies. It updates when either getHealth or getMaxHealth change.
In the screen shot below, I've added logging messages to the function portion of each selector. The selector name will be logged each time its function is executed.
Notice the first call to isHealthLow causes the entire chain of dependencies to be evaluated. However, game state was unchanged when the second call was made and so only the root dependencies were evaluated. Finally, the hero gained some experience and leveled up, which affected XP and max health, which caused the third isHealthLow call to re-evaluate the entire chain again.
Experimenting on your own
You've now seen how using reselect can help you cache operations that compute state.
Add reselect to your JSFiddle with https://npmcdn.com/[email protected]/dist/reselect.js
Coming up next...
In Part 4: Every Hero Needs a Villain we'll add a game loop with monster battles. We'll also see that reducers and action creators are the wrong place to put this kind of application logic, and I'll show you where it belongs.
Catch up on other installments:
- Part 1: A Hero is Born (A Fun Introduction to Redux.js)
- Part 2: Actions and Their Consequences (a Fun Introduction to redux-actions)
- Part 3: Choose Wisely (a Fun Introduction to reselect.js)
- Part 4: Every Hero Needs a Villain (a Fun Introduction to redux-saga)
- Part 5: The Hero is Tested (a Fun Introduction to redux-saga-test-plan)