A blue slime draws near...

Redux Hero Part 4: Every Hero Needs a Villain (A Fun Introduction to redux-saga.js)

Redux-Saga Redux

When you think about a classic RPG like Dragon Warrior or Final Fantasy, the bulk of the game involves wandering around on a map and getting into fights with monsters. Does the hero encounter a monster on every tile? No. Randomness is the key.

But where does randomness fit in a Redux world where everything is supposed to be a pure function? Deterministic and without side effect.

And there's a larger question of Where does my application logic belong in general? It's difficult to reason about an application when the logic is scattered across multiple reducers, each of which has a signal-to-noise ratio that is a lot lower (by design!) than other average code you write.

Fortunately, redux-saga provides a nice solution to these problems. We're going to build up some logic for our mock game that demonstrates this.

First, let's define pseudocode for the core of our game:

loop while player is still alive    wait for player to move    are we in a safe place?    randomly decide if there is a monster    fight the monsterend loop

Let's start really simple... we're going to implement one line of pseudocode at a time.

While the Player is Still Alive

export function* gameSaga() {    // loop while player is still alive    let playerAlive = true;    while (playerAlive) {        // ...    }}

Redux-saga relies on generator functions. I'm going to gloss over these for the sake of simplicity. If you'd like to know more, I'd encourage you to read (function on MDN) and (ES6 Generators by David Walsh) first. As far as this article is concerned, just know that we need to put an asterisk after the function keyword and we'll yield* everything rather than calling things directly. We're also writing in ES6, which you can compile down to ES5 via Babel.

Wait for the Player to Move

In terms of actual mechanics, we would have a MOVE action type, a move() action creator, and some part of our UI would respond to a keyboard event or mouse event and dispatch the action.

const Actions = {    MOVE: 'MOVE',    // ...};const move = ({ x, y }) => ({    type: Actions.MOVE,    payload: { x, y }});

Once dispatched, the action passes through the redux-saga middleware and that's where the magic begins.

export function* gameSaga() {    let playerAlive = true;    while (playerAlive) {        // wait for player to move        yield take(Actions.MOVE);    }}

The take effect causes the saga to block until the specified action gets dispatched. Note, this does not block your UI or any other processing on your page. A saga acts almost like a background process.

Are we In a Safe Place?

We wouldn't expect to run into any monsters inside a house or in a friendly castle...

export function* gameSaga() {    let playerAlive = true;    while (playerAlive) {        yield take(Actions.MOVE);        // are we in a safe place?        const location = yield select(getLocation);        if (location.safe) continue;    }}

The select effect allows us to access state from our redux store. In this example, getLocation is a selector that might look something like this:

export const getLocation = state => {    const { x, y } = state.hero.position;    return worldMap[ y, x ];};

Redux-saga has a reference to our store and it calls getState on our behalf, passing the state into our selector. The result of the selector gets deposited into our saga as the return from the select effect.

Randomly Decide if There is a Monster

We can't put calls to Math.random() inside a reducer. One of the Three Principles of Redux is that the reducers have to be pure. That is, they must be free of side effects (i.e. they are prohibited from directly changing any application state. All they are allowed to do is return a new state) and they can only depend on input values (i.e. cannot pull data from other sources such as globals, API calls, etc).

Math.random() is considered an impure function because it is non-deterministic (i.e. it does not return the same value every time it is called) and it alters global state.

So this would be considered a No-No in Redux:

// Example of what NOT to doexport const reducer = (state = {}, action) => {    switch (action.type) {        case Action.MOVE:            const monsterProbability = Math.random(); // BAD!            if (monsterProbability > location.encounterThreshold) {                // We encountered a monster            }            return newState;    }};

However, we can introduce randomness through our saga like this:

export function* gameSaga() {    let playerAlive = true;    while (playerAlive) {        yield take(Actions.MOVE);        const location = yield select(getLocation);        if (location.safe) continue;        // randomly decide if there is a monster        const monsterProbability = yield call(Math.random);        if (monsterProbability < location.encounterThreshold) continue;    }}

We didn't actually execute Math.random(). By yielding a call effect, we're telling the redux-saga middleware to make the call for us and give the return value to our saga.

Fight the Monster

The fight sequence will get a little complicated, so we should create this as a separate saga. Here is our completed game saga that defers to a new saga called fightSaga. We will make fightSaga return true if the player is still alive and false if the player died.

Also notice that the call effect can be used to execute sagas in addition to regular functions.

export function* gameSaga() {    let playerAlive = true;    while (playerAlive) {        yield take(Actions.MOVE);        const location = yield select(getLocation);        if (location.safe) continue;        const monsterProbability = yield call(Math.random);        if (monsterProbability < location.encounterThreshold) continue;        // fight the monster        playerAlive = yield call(fightSaga);    }}

Here we're starting with pseudocode for the new fight saga.

begin loop    monster's turn to attack    is player dead? return false    player fight options    is monster dead? return trueend loop

Translating into ES6, our fight saga looks like this:

export function* fightSaga() {    // for convenience, save a reference to the monster    const monster = yield select(getMonster);    while (true) {        // monster attack sequence        yield call(monsterAttackSaga, monster);        // is player dead? return false        const playerHealth = yield select(getHealth);        if (playerHealth <= 0) return false;        // player fight options        yield call(playerFightOptionsSaga);        // is monster dead? return true        const monsterHealth = yield select(getMonsterHealth);        if (monsterHealth <= 0) return true;    }}

I think it's important to keep sagas to a reasonable size. We could have put the monster attack and player attack sequences inline, but the whole system will be easier to unit test if we have smaller, more-manageable chunks. Keep the single-responsibility principle in mind when developing sagas. Here, fightSaga is responsible for the order of the fight (i.e. monster first, then player, then monster, etc. until only one is still standing).

Notice that we can pass parameters in the call effect (we're telling redux-saga to pass the monster object as a parameter to monsterAttackSaga).

Next we'll define monsterAttackSaga and playerFightOptionsSaga.

Monster's Turn to Attack

Let's think about gameplay for a moment. It would be an awkward experience for the player if the monster attacks immediately after the player has made a move. So we're going to introduce a short delay as the first step in the monster attack sequence.

Here's the pseudocode for the monster attack sequence:

wait a small delaygenerate random damage amountplay an attack animationapply damage to the player

And written in JavaScript (hey, remember we passed in the monster as a parameter to the saga):

export function* monsterAttackSaga(monster) {    // wait a small delay    yield call(delay, 1000);    // generate random damage amount    let damage = monster.strength;    const critProbability = yield call(Math.random);    if (critProbability >= monster.critThreshold) damage *= 2;    // play an attack animation    yield put(animateMonsterAttack(damage));    yield call(delay, 1000);    // apply damage to the player    yield put(takeDamage(damage));}

Redux-saga has a put effect for dispatching actions. In this example, we've got action creators called animateMonsterAttack and takeDamage (takeDamage was defined back in Part 2 and just pretend animateMonsterAttack exists and is awesome).

Player Fight Options Sequence

The player sequence is a little more involved than the monster sequence because more things can happen. For example, suppose the player could choose to attack, drink a healing potion or run away. I'll leave each of the sub-sequences as an exercise for you. But I do want to show you one more redux-saga feature here.

First, the pseudocode:

wait for player to select an actionif attack, run the attack sequenceif potion, run the heal sequenceif run away, run the escape sequence

And now the JavaScript:

export function* playerFightOptionsSaga() {    // wait for player to select an action    const { attack, heal, escape } = yield race({        attack: take(Actions.ATTACK),        heal: take(Actions.DRINK_POTION),        escape: take(Actions.RUN_AWAY),    });    if (attack) yield call(playerAttackSaga);    if (heal) yield call(playerHealSaga);    if (escape) yield call(playerEscapeSaga);}

In redux-saga, the race effect blocks the saga until one of the specified actions occurs. It returns an object that contains the fired action.

We can put any kind of effect in a race -- even a call to another saga -- and all of the losing effects will be cancelled. Handling cancelled effects is a more advanced topic. So I'll leave the explanation for another time. But here's how we can use the race effect to allow the player to abandon the current game state to load from a saved game.

export function* metaSaga() {    // wait for assets to load    // show intro screen    // wait for player to start the game    // play the game and also watch for load game    while (true) {        yield race({            play: call(gameSaga),            load: take(Actions.LOAD_GAME),        });    }}

A reducer handles the LOAD_GAME action by overwriting the entire game state and then redux-saga terminates the gameSaga regardless of whether we were waiting for the player to move on the map or choose an option in a fight, etc.

Recap

In this installment, I showed you how to use some basic redux-saga functionality.

Effect Purpose
take to wait for an action
select to access state
call to call a function or another saga
delay to delay execution
put to dispatch an action
race to wait for the first completion from a set of effects

There are several more-advanced functions and so much more you can do with redux-saga. I hope you enjoyed this introduction to the basics. Read more in the official Redux-Saga documentation.

Exercises for the Reader

Here are some ideas in order of approximate difficulty that you can try on your own:

You might also enjoy...


Level up Your React + Redux + TypeScript

for free with articles, tutorials, sample code, and Q&A.