Learning to think in FRP: My experience coding a game with Kefir.js

Learning to think in FRP: My experience coding a game with Kefir.js

Functionally reactive programming is a buzz topic lately, and part of a larger trend towards immutable data structures, uni-directional flow, and side-effect-free, pure functions. I’ve subscribed to this style of coding, working heavily with react.js, flux, and ramda.js, so I’ve been keen to try out something like bacon.js, or kefir.js, or even ELM.

I decided to build a simple memory card game (also known as “Concentration”) to learn and get some hands-on experience with FRP. I expected things to go relatively smoothly, as I am well acquainted with functional programming, and have even built my own async, pure functional framework for another one of my games.

However, I found it very challenging. Through my troubles, I discovered some insights and a few helpful design principles and best practices when working with FRP, that I think will help others. I expect I hit many of the common stumbling-blocks in FRP, so I wanted to share my experience.

Caveat emptor: I realise how presumptuous it is offer “best practices,” when I am so new to FRP. My approaches may be wrong; feel free to correct me in the comments.

Stream slingin’ ninja

FRP is about streams, right? Cool, I can mix and match streams together no problem, this isn’t so hard!

After a few minutes my basic game logic was all defined:

// stream of card id and image from clicks on each card
flipStream = Kefir.merge(clicksFromEachCardView);

flipCountStream = flipStream.scan(R.add(1), 0);

firstCardStream = flipStream.filterBy(flipCountStream.map(isEven));
secondCardStream = flipStream.filterBy(flipCountStream.map(R.compose(R.not, isEven)));

// stream of card tuples
pairsStream = Kefir.zip([firstCardStream, secondCardStream]);

matchStream = pairsStream.map(R.apply(R.eqProps("image")));

roundCountStream = firstCardStream.scan(R.add(1), 0);

// side effects
roundCountStream.log("Round:");
firstCardStream.map(R.prop("image")).log("First card:");
secondCardStream.map(R.prop("image")).log("Second card:");
matchStream.log("IsMatch?:");

The console logs were working just fine — I was about done here, now just to render the cards instead of log. Or so I thought…

Hurdle #1: Oops, where’s the models?

I had a great intertwined flow of streams, but all I could do with it was make side effects. Not pure. Not FRP. I was missing the properties (the card models) for the views to render against, which updated over time with the changes (face-up, face-down, matched). Without any models, I was back to the pre-backbone.js era — holding state in the DOM. No good.

FRP best practice #1: Consider each of your models in your domain, and everything that affects them (both raw inputs and “derived” streams).

Each stream / property should be able to define the relationships between every outside source that affects it, within its own definition.

And definitely make stream charts (or “marbles graphs”) to help visualise these relationships!

So I broke it down. I need a model for each card in play. Each card responds to it’s corresponding view’s clicks to turn face-up, and the match stream to either turn face-down or matched (after a short delay).

/*
Streams: (note, cards 2 and 3 match)
card1 clicks   ---1---------------------->
card2 clicks   ------2-------------2----->
card3 clicks   ------------3--3---------->
flips          0--1--2-----3-------4----->
rounds         0--1--------2------------->
match          ------f-------------t----->
reset cards    f-------f-------------t--->
card1 status   d--u----d----------------->
card2 status   d-----u-d-----------u-m|
card3 status   d-----------u---------m|

Card status:
d = face down
u = face up
m = matched
*/

That looked good on paper, so I tried to code it.

reset = match.delay(1000);

getCardStream = function (card) {
  // filter for corresponding card
  return flipStream
    .filter(R.contains(card))
    .scan(function (card, event) {
      // TODO respond to reset stream too somehow?
      return R.merge(card, {
        status: CARD_STATES.FACE_UP
      });
    }, card);
};

cards = Kefir.merge(R.map(getCardStream, arrayOfCardObjects));

// side effect to log each card as it changes
cards.log("Card data");

Better, but I still had a problem — nothing prevented the user from clicking the same card twice in a row, which would trigger a flip event, and thus a match event, even though only one card was face-up! I needed a way to prevent clicks on face-up cards from coming through.

Hurdle #2: Circular dependency troubles

Naturally, I tried filtering out flip events when the corresponding card was already face up. But this didn’t work, because my flip stream relied on my card, which itself relied on the flip stream.

I finally realised that there’s no clever way to get around circular dependencies like this, other than by changing the relationships between your streams (or by using a “pool,” or “bus” in bacon.js, but that can get you in trouble, as it is equivalent to mutating state, undermining your nice one-directional flow).

I decided that I needed a new node in my app that governed if a click was valid or not, independent of what the eventual card values were.

FRP best practice #2: Use the lowest-level sources possible to derive data from.

FRP best practice #3: Keep your code modular by making specific streams to encapsulate a single behaviour, and compose these “functional stream units” together later.

validFlip = Kefir
  .merge(cardClicks)
  .scan(function (acc, event) {
    var faceUps;
    if (acc.faceUps.length === 2) {
      // this is the first flip of next pair (always valid)
      return {
        faceUps: [event],
        valid: true
      };
    } else {
      // second flip (must be a different card)
      if (R.contains(event, acc.faceUps)) {
        return R.merge(acc, {
          valid: false
        });
      } else {
        // second card is valid, so append it to the first card
        faceUps = R.append(event, acc.faceUps);
        return {
          faceUps: faceUps,
          valid: true
        };
      }
    }
  }, {
    faceUps: [],
    valid: false
  })
  .map(R.prop("valid"));

// now only stores valid first and second flips in array
faceUps = Kefir
  .merge(cardClicks)
  .filterBy(validFlip)
  .scan(function (faceUps, event) {
    if (faceUps.length === 2) {
      // start a new pair
      return [event];
    } else {
      return R.append(event, faceUps);
    }
  }, []);

// only fire when faceUps has two cards loaded
match = faceUps
  .filter(R.compose(R.eq(2), R.length))
  .map(R.apply(R.eqProps("image")));

// TODO refactor card modules to respond to faceUps instead of flipStream

Now I could rely on the events coming through to my cards. But I still had to figure out how to update my cards differently depending on which events came through.

Hurdle #3: Combinator confusion

Even though the relationships seemed rather straightforward, it took a few passes and a few failed attempts to get accurate card models.

I tried to do all kinds of fancy things with combine, flatMapLatest, take(1), and such, but none of it worked, and it just caused confusion, guesswork, and unexpected results.

What I wanted was a means of responding in one way when getting a signal from one stream, and another way when getting a signal from another.

FRP best practice #4: Streams tell you what and when. Make sure the “whens” hit at the right times, and carefully consider what information you need to pass through in the “whats”. Most of the FRP library api tools affect one or both of these two properties of a stream, so figure out what you need, then pick the tool that satisfies that.

I eventually stumbled upon a nifty little pattern I call the map’n’merge. I think it is similar to a FRP version of a map-reduce.

getCardStream = function (card) {
  // mapping functions to normalise events:
  var faceUpToAction, resetToAction;
  faceUpToAction = function (faceUps) {
    // (only the last of the faceUps array is new)
    return {
      affectedCards: [R.last(faceUps)],
      status: CARD_STATES.FACE_UP
    };
  };
  resetToAction = function (reset) {
    return {
      affectedCards: reset.affectedCards,
      status: reset.isMatch ? CARD_STATES.MATCHED : CARD_STATES.FACE_DOWN
    };
  };

  //map'n'merge
  return Kefir
    .merge([faceUps.map(faceUpToAction), reset.map(resetToAction)])
    .filter(R.compose(R.contains(card.id), R.prop("affectedCards")))
    .scan(function(card, action) {
      return R.merge(card, {
        status: action.status
      });
    }, card);
};

cardStreams = Kefir.merge(R.map(getCardStream, arrayOfCardObjects));

With that in place, I was finally ready to render my views off of my card models, turning face-up, face-down, or going away depending on the card model’s status.

Hurdle #4: Circling back…

I still have one nagging problem — I want to turn off a view’s click stream when its model’s status goes to matched. Filtering my valid flips by my card’s status seems like a good approach, except that now I’m back to circular dependencies again. Perhaps using a pool would be acceptable for this, but I wanted to avoid it.

The solution I went with was simply to remove the view from the DOM (and turn off its click events) when responding to the match status.

Similarly, I want to prevent flips during the period between two face up cards and the reset event, so I have to toggle the click events on and off for all cards at those times.

This works, but it does mutate state. Since it is happening in the side-effect phase of FRP perhaps it is excusable. Indeed, it seems to mirror how react and flux would accomplish this behaviour.

Conclusion

FRP is an interesting approach to game development, bringing some very nice sanity to your code, but it is also challenging to get used to. Instead of mutating state in a “push” fashion, you define your modules reactively in a “pull” manner, based on the what and when that streams provide you.

FRP libraries like kefir.js offer many functional tools for managing your streams, which means you have lots of ways to achieve your desired behaviours. This can be difficult, as it is hard to know what approach to take, especially when starting with FRP, but also good, because you can usually find a different, better approach if things aren’t working.

In trying to make a game with FRP, I ran into many of the common hurdles, but through my struggles I arrived at some helpful patterns and “best practices” to help guide me, and I hope they can help you as well.