Building a Tic Tac Toe game with Cowboy & websockets (Part 4)

Saving game state

As discussed in Part 3, if the FSM crashes it will be re-started, but the game state will be lost. Which isn’t ideal.

The easiest way to solve this is to store the state record in an Ets or Dets table (the main difference being whether it is stored on disk or just in memory). I decided to use a Dets table, although if the VM were to crash (or be stopped deliberately) the game supervisor would not restart the child FSMs when it came back up (yet, there are some other problems to solve (e.g. restoring sessions) before that would be possible).

The first step was to add the game id, and current FSM state to the state record:

-record(state, {game_id, current_state=p1_turn, p1, p2, board=#{
    <<"1,1">> => '_', <<"1,2">> => '_', <<"1,3">> => '_',
    <<"2,1">> => '_', <<"2,2">> => '_', <<"2,3">> => '_',
    <<"3,1">> => '_', <<"3,2">> => '_', <<"3,3">> => '_'

And update the init function to open the table, and check if any state already exists for that game id:

init(Args) ->
    [{P1, P2, GameId}] = Args,
    process_flag(trap_exit, true),
    true = gproc:reg({n, l, GameId}),
    {ok, t3_game_state} = dets:open_file(t3_game_state, []),
    State = get_game_state(GameId, P1, P2),
    {ok, State#state.current_state, State}.

get_game_state(GameId, P1, P2) ->
    case dets:lookup(t3_game_state, GameId) of
        [] ->
            save_game_state(#state{game_id = GameId, p1 = P1, p2 = P2});
        [{GameId, State}] ->

We also trap exits; so the terminate function of the FSM is called, and we can close the table:

terminate(_Reason, _StateName, _State) ->

Finally, we save the game state after any successful FSM transition:

p1_turn({play, P1, Cell}, State = #state{p1 = P1}) ->
    NewState = play(Cell, State, 'O', p2_turn),
    Res = case t3_game:has_won(NewState#state.board, 'O') of
        true ->
            game_won(NewState#state.p1, NewState#state.p2, NewState#state.board),
            {stop, normal, NewState};
        false ->
            case t3_game:is_draw(NewState#state.board) of
                true ->
                    game_drawn(NewState#state.p1, NewState#state.p2, NewState#state.board),
                    {stop, normal, NewState};
                false ->
                    {next_state, p2_turn, NewState}

save_game_state(State = #state{game_id = GameId}) ->
    ok = dets:insert(t3_game_state, {GameId, State}),

Now, if the process crashes, it will be resurrected with the state rolled back to before the offending message. Of course in this system, it’s likely that any crash will be repeated if the same message arrives at the same state; but if your application is likely to suffer from transient errors (e.g. network errors calling another system) then you can just “let it crash”.

There are, of course, some trade-offs to this solution. A Dets table is only available to one Erlang node, if you wanted to scale out it would make more sense to use replicated Mnesia or an external data store such as Postgres, or Riak. And the shared Dets table could become a bottleneck when there were many games running.

A more pressing problem would be handling versioning of the game state, this naive code would crash if the record was changed and an old version was retrieved from the data store.

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s