Pyrus: Using Redux on the server to manage state in a collaborative game to support problem-solving

January 01, 2018

Over the past few quarters, my partner and I have been working on a research project focused on learning more about how we might design a collaborative coding game which helps novice computer science students develop their programming problem-solving skills. Our system is called Pyrus and the project is advised by Nell O’Rourke as part of a class called Design, Technology and Research. We recently submitted a paper to CHI 2018’s Student Research Competition, which focuses mostly on the design arguments for our system and our research contributions, but didn’t give us a chance to talk about some of the interesting technical challenges we faced. In this post, I’ll be going over how we handled one interesting technical hurdle by using Redux on the back-end to synchronize game state between a server and multiple clients.

The Challenge

To provide some context, our system consists of a game which has a few standard game-like components, like a board and a deck of cards. In this game, players can take actions that mutate the state of the game across multiple components, e.g. drawing a card would pop a card off the top of the deck and push it onto the player’s hand. All these changes need to be propagated to all clients in real time and must be handled the same way to ensure that state changes are consistent.

We went back and forth on a few different methods for managing state, but we eventually realized that Redux was probably the best way to go for a couple of reasons:

From a bit of digging online, we realized the typical way to keep state in sync between a server (the source of truth) and multiple clients in multiplayer games is to keep game state stored in a well-defined object model and transmit all changes to game state as discrete events. This is pretty much Redux’s bread and butter.

Using Redux on the back-end meant we could reuse much of our Redux code (e.g. actions, reducers, etc) on the front-end. Not only did it save us a lot of time from not having to write and maintain two sets of code that did the same thing, it also ensured that all actions were handled in exactly the same way between client and server.

We also needed the ability to intercept actions (e.g. in order to log them to a file) as they were dispatched. Redux allows you to do this really well through middleware.

But there was one sticking point: Redux is usually paired with a view library like React, and we weren’t able to find much information online about Redux being used on the server. This meant we had our work cut out for us as far as figuring out the best way to organize our application.

The Solution

Our solution was to create a Game class which configures a Redux store in its constructor. Once a Game object is instantiated, its store becomes a member variable of that object, i.e. accessible through this._store. This is what we use in place of traditional member variables to mutate the state of the game. Now any time we want to dispatch an action, we can simply call this._store.dispatch(someActionCreator()); from within the Game itself.

Being able to do this is nice because it affords us a few benefits:

We can expose a very simple API for interacting with the Game object. Outside code can call methods like game.start() instead of dispatching actions directly on a store themselves. (This abstraction carries over to all other objects, like our object for Deck or Board, since we construct those from the Game object we pass down the store to them too.) Additionally, if we ever need to fire multiple actions in sequence, we can just do that in a very straightforward way by simply writing out those dispatches one after another. No messing with thunks necessary.

We can also keep all the business logic in the classes. There are a lot of checks in place to make sure that the game doesn’t end up in an invalid state, e.g. by allowing an inactive player to make a move or by executing an action that was actually illegal. Without a class to wrap all these checks around, they would probably all end up in the action. This object-oriented approach makes our code much easier to understand.

This also provides an easy, reliable way to access member variables. Instead of keeping a laundry list of member variables that we mutate ourselves, we define getters which simply return the corresponding attribute via a call to this.store.getState().attrib within the class. This means we can avoid storing redundant information.

To take a look at how this works in practice, here’s some annotated example code (the following snippet is close to our implementation, but with some simplifications made for illustration purposes):

import{createStore}from'redux';importrootReducerfrom'../../reducers';import{gameStart}from'../../actions/game';importBoardfrom'../Board';exportdefaultclassGame{// the constructor initializes the Redux store and puts it// in a member variable of the Game objectconstructor(){this._store=createStore(rootReducer);// the "board" here is an object which encapsulates all// "physical" game components, e.g. players and the deck.// all components get passed the storethis.board=newBoard(this._store);}// this getter exposes a clean way for outside code to get // information from the Redux store without having to know// the actual layout of the store treegetstatus(){returnthis._store.getState().game.status;}// returns whether the game successfully startedstart(){// a check to make sure we don't end up in an invalid// state (i.e. starting the game when we shouldn't be.if(this.status!=='GAME_STATUS_INIT'){returnfalse;}// to mutate state, we dispatch actions to our this._storethis._store.dispatch(gameStart());// we can easily dispatch multiple subsequent actionsfor(letiinthis.board.players){// like status above, board.players is actually a getter// that fetches an attribute from the state treeconstplayer=this.board.players[i];// bonus: the deck.draw function called here also// dispatches multiple actions to the store. since// we passed in the store in the board, which in turn// passes it to the deck, we can implement the draw // function in the deck and provide a much simpler// abstraction to use hereconsthand=this.board.deck.draw(4);this._store.dispatch(setPlayerHand(hand,player.id));}returntrue;}}

As mentioned above, using the same representation of game state on the server and the client made managing updates between clients really easy. We basically set up a WebSocket connection between the server and all the clients whenever a new client joins a game. On that connection, the server sends over the JSON representation of the current game state. Once that connection is established, the client will send a message to the server whenever the player performs an action (the JSON.stringify()’d Redux action). The server takes each message, parses it, does all the necessary checking, and handles that action. Attached to the store on the back-end is some custom middleware that intercepts every dispatched action and broadcasts it to all clients listening on that WebSocket connection. Those clients then receive those incoming WebSocket messages and dispatch each action on their own stores. This way, we guarantee that every state mutation the server applies is sent to the client as well.

This was an interesting problem to think through and it definitely took some time before we fell into a comfortable paradigm with it, but in the end I think it worked out really well. The ease of moving between the front- and back-end codebases, possible because that were both in Javascript they shared almost all their Redux code, was actually tremendously helpful.

I was thinking about abstracting some of the work we’ve done into a generalizable framework for writing multiplayer games in a full Javascript stack, but then a friend of mine recently pointed me to boardgame.io. At time of writing it’s pretty new, but it provides a good layer of abstraction over all that state-management stuff and is conceptually very similar to what I outlined above. Check it out if you’re interested in a solution similar to what I’ve discussed that’s ready out-of-the-box.