Minimal web app with cowboy & rebar3

The erlang docker images now include rebar3, so it’s very easy to get started with a new project (the name needs to be an erlang term, so underscores not dashes!):

$ docker run -v $PWD:/app/foo_app -w /app erlang:25 rebar3 new release foo_app
Unable to find image 'erlang:25' locally
25: Pulling from library/erlang
e756f3fdd6a3: Pull complete 
...
Digest: sha256:4eafc58e4475a7be2416af55ea142a7cd00c14b6ec2490a38db3a0869efde7e4
Status: Downloaded newer image for erlang:25
===> Writing foo_app/apps/foo_app/src/foo_app_app.erl
===> Writing foo_app/apps/foo_app/src/foo_app_sup.erl
===> Writing foo_app/apps/foo_app/src/foo_app.app.src
===> Writing foo_app/rebar.config
===> Writing foo_app/config/sys.config
===> Writing foo_app/config/vm.args
===> Writing foo_app/.gitignore
===> Writing foo_app/LICENSE
===> Writing foo_app/README.md

As dockerd is running as root, you then need to chown the generated files (you can run the docker cmd as the current user, but that didn’t go well when I tried it).

Then you need a Dockerfile:

# Build stage 0
FROM erlang:25-alpine

RUN apk add --no-cache git

# Set working directory
RUN mkdir /buildroot
WORKDIR /buildroot

# Copy our Erlang test application
COPY rebar.config .
COPY apps/ apps/
COPY config/ config/

# And build the release
RUN rebar3 as prod release

# Build stage 1
FROM alpine

# Install some libs
RUN apk add --no-cache openssl ncurses-libs libstdc++

# Install the released application
COPY --from=0 /buildroot/_build/prod/rel/foo_app /foo_app

# Expose relevant ports
EXPOSE 8080

CMD ["/foo_app/bin/foo_app", "foreground"]

At this point, you should be able to build the image:

$ docker build -t foo_app .
Sending build context to Docker daemon  104.4kB
Step 1/13 : FROM erlang:25-alpine
25-alpine: Pulling from library/erlang
2408cc74d12b: Already exists 
1e90e213ba89: Pull complete 
Digest: sha256:1fdd18a383206eeba257f18c5dd22d7f381942eda4cd76a88e36a1d3247c4130
Status: Downloaded newer image for erlang:25-alpine
 ---> 8f1437fc7749
Step 2/13 : RUN apk add --no-cache git
 ---> Running in 1af099a684a7
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
(1/6) Installing brotli-libs (1.0.9-r6)
(2/6) Installing nghttp2-libs (1.47.0-r0)
(3/6) Installing libcurl (7.83.1-r1)
(4/6) Installing expat (2.4.8-r0)
(5/6) Installing pcre2 (10.40-r0)
(6/6) Installing git (2.36.1-r0)
Executing busybox-1.35.0-r13.trigger
OK: 23 MiB in 30 packages
Removing intermediate container 1af099a684a7
 ---> 99d4529dc490
Step 3/13 : RUN mkdir /buildroot
 ---> Running in 47daa05e777b
Removing intermediate container 47daa05e777b
 ---> ac097064f098
Step 4/13 : WORKDIR /buildroot
 ---> Running in 51479ca5c35a
Removing intermediate container 51479ca5c35a
 ---> 649f740b900b
Step 5/13 : COPY rebar.config .
 ---> 3fe63710afa6
Step 6/13 : COPY apps/ apps/
 ---> 91e46ef9dfc9
Step 7/13 : COPY config/ config/
 ---> 4194973d4e23
Step 8/13 : RUN rebar3 as prod release
 ---> Running in 7e7a2112d86e
===> Verifying dependencies...
===> Analyzing applications...
===> Compiling foo_app
===> Assembling release foo_app-0.1.0...
===> Release successfully assembled: _build/prod/rel/foo_app
Removing intermediate container 7e7a2112d86e
 ---> 91154e8634db
Step 9/13 : FROM alpine
latest: Pulling from library/alpine
2408cc74d12b: Already exists 
Digest: sha256:686d8c9dfa6f3ccfc8230bc3178d23f84eeaf7e457f36f271ab1acc53015037c
Status: Downloaded newer image for alpine:latest
 ---> e66264b98777
Step 10/13 : RUN apk add --no-cache openssl ncurses-libs libstdc++
 ---> Running in 935857dec575
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
(1/5) Installing libgcc (11.2.1_git20220219-r2)
(2/5) Installing libstdc++ (11.2.1_git20220219-r2)
(3/5) Installing ncurses-terminfo-base (6.3_p20220521-r0)
(4/5) Installing ncurses-libs (6.3_p20220521-r0)
(5/5) Installing openssl (1.1.1o-r0)
Executing busybox-1.35.0-r13.trigger
OK: 9 MiB in 19 packages
Removing intermediate container 935857dec575
 ---> e62baafed2cd
Step 11/13 : COPY --from=0 /buildroot/_build/prod/rel/foo_app /foo_app
 ---> d768a4b774ea
Step 12/13 : EXPOSE 8080
 ---> Running in 11f013006a16
Removing intermediate container 11f013006a16
 ---> 0e347e1c2d46
Step 13/13 : CMD ["/foo_app/bin/foo_app", "foreground"]
 ---> Running in 7bf493256ded
Removing intermediate container 7bf493256ded
 ---> dc0f1632d27f
Successfully built dc0f1632d27f
Successfully tagged foo_app:latest

This is a good approach for building a final deployable image, but it’s a bit painful for local development, and doesn’t make the best use of the docker layer caching. You probably want to use a mounted vol instead.

You should now be able to run the application:

$ docker run -p 8080:8080 --rm foo_app
Exec: /foo_app/erts-13.0.1/bin/erlexec -noinput +Bd -boot /foo_app/releases/0.1.0/start -mode embedded -boot_var SYSTEM_LIB_DIR /foo_app/lib -config /foo_app/releases/0.1.0/sys.config -args_file /foo_app/releases/0.1.0/vm.args -- foreground
Root: /foo_app
/foo_app

Unfortunately Ctrl+C doesn’t work, so you need to docker kill it.

This isn’t a web app, yet, so we need to add cowboy to the dependencies list in rebar.config:

{deps, [
    {cowboy,"2.9.0"}
]}.

I’m using the latest tag, but you might want a specific version. If you build again, you should see some extra steps:

Step 8/13 : RUN rebar3 as prod release
 ---> Running in 95791181399b
===> Verifying dependencies...
===> Fetching cowboy v2.9.0
===> Fetching cowlib v2.11.0
===> Fetching ranch v1.8.0
===> Analyzing applications...
===> Compiling cowlib
===> Compiling ranch
===> Compiling cowboy
===> Analyzing applications...
===> Compiling foo_app
===> Assembling release foo_app-0.1.0...
===> Release successfully assembled: _build/prod/rel/foo_app

You also need to add cowboy to the list in the app.src file:

  {applications,
   [kernel,
    stdlib,
    cowboy
   ]},

You can then configure cowboy, in your _app file:

start(_StartType, _StartArgs) ->
    Port = 8080,
    Dispatch = cowboy_router:compile([
        {'_', [{"/ping", ping_handler, []}]}
    ]), 
    {ok, _} = cowboy:start_clear(my_http_listener,
        [{port, Port}],
        #{env => #{dispatch => Dispatch}}
    ), 
    foo_app_sup:start_link().

And create a handler:

-module(ping_handler).

-export([init/2]).

init(Req, State) ->
    {ok, Req, State}.

This is the absolute minimum, and will return a 204 for any request:

$ curl -I -XGET "http://localhost:8080/ping"
HTTP/1.1 204 No Content

Or, for something a bit more realistic:

init(#{method := Method} = Req, _State) ->
    handle_req(Method, Req).

handle_req(<<"GET">>, Req) ->
    {ok, text_plain(Req, <<"pong">>)};

handle_req(_Method, Req) ->
    cowboy_req:reply(404, Req).

text_plain(Request, ResponseBody) ->
    ResponseHeaders = #{
        <<"content-type">> => <<"text/plain">>
    },--
    cowboy_req:reply(200, ResponseHeaders, ResponseBody, Request).

This will return some text, for a GET:

$ curl "http://localhost:8080/ping"
pong

And a 404 for any other HTTP method:

$ curl -I -XPOST "http://localhost:8080/ping"
HTTP/1.1 404 Not Found
content-length: 0
date: Fri, 17 Jun 2022 11:54:35 GMT
server: Cowboy

Authentication with cowboy & stormpath

I’ve been meaning to try out an “authentication as a service” offering for a little while now. The first one I had come across was Auth0, which looks very slick, particularly if you use their “Lock” frontend.

Unfortunately, their documentation didn’t seem to have much information on what to do if you’re using a language they don’t provide an SDK for. I’m sure I could have deconstructed the node version, but I decided to try out Stormpath instead.

My weapon of choice at the moment is Cowboy, which is quite barebones; more of an http toolkit than a web framework (whether this is good or bad has been discussed, at length, elsewhere). To make things more interesting, I also wanted to support a mixture of secure and insecure content.

I chose to use an existing solution for session management, rather than re-inventing all the wheels at once; and to use Hackney (from an ever expanding roster of clients) for http requests.

Users need to be able to login:

handle_req(<<"POST">>, Req) ->
    {ok, Body, Req2} = cowboy_req:body_qs(Req),
    Email = proplists:get_value(<<"email">>, Body),
    Password = proplists:get_value(<<"password">>, Body),
    case stormpath:login(Email, Password) of
        {ok, UserInfo} ->
            {ok, Req3} = cowboy_session:set(<<"user">>, UserInfo, Req2),
            redirect_to(<<"/user/info">>, Req3);
        {error, Error} ->
            render_login_page([{error, Error}, {email, Email}], Req2)
    end;

which calls the Stormpath API:

login(Email, Password) ->
    Uri = get_stormpath_uri(<<"/loginAttempts">>),
    ReqBody = #{type => <<"basic">>, value => base64:encode(<<Email/binary, ":", Password/binary>>)},
    ReqJson = jiffy:encode(ReqBody),
    {ok, StatusCode, _, ClientRef} = stormpath_request(post, Uri, ReqJson),
    {ok, RespBody} = hackney:body(ClientRef),
    RespJson = jiffy:decode(RespBody, [return_maps]),
    case StatusCode of
        200 ->
            get_user_info(RespJson);
        400 ->
            get_error_message(RespJson);
        _ ->
            generic_error()
    end.

It’s then possible to check if a user is logged in:

execute(Req, Env) ->
    {Path, _} = cowboy_req:path(Req),
    check_path(Path, Req, Env).

check_path(<<"/user/", _/binary>>, Req, Env) ->
    case logged_in(Req) of
        false ->
            redirect_to(<<"/login">>, Req);
        true ->
            {ok, Req, Env}
    end;

check_path(_, Req, Env) ->
    {ok, Req, Env}.

logged_in(Req) ->
    {User, _} = cowboy_session:get(<<"user">>, Req),
    case User of
        undefined -> false;
        _ -> true
    end.

redirect_to(Location, Req) ->
    {ok, Req2} = cowboy_req:reply(302, [{<<"Location">>, Location}], Req),
    {halt, Req2}.

before processing a request; and bouncing them to the login page if they aren’t, when they should be.

You can find the source here. I think using one of these services could definitely save you some time at the start of a new project, as well as removing the risk of making common security blunders. On the other hand you take on their uptime as well as your own, and you are locked in to using their service should the pricing become unattractive (although Stormpath, at least, allows you to export your data). YMMV.

Handling a POST with Cowboy

It’s not immediately obvious how to handle different HTTP methods using Cowboy.
This SO answer, and one of the examples show the way:

handle(Req, State) ->
	{Method, Req2} = cowboy_req:method(Req),
	{ok, Req3} = process_req(Method, Req2),
	{ok, Req3, State}.

process_req(<<"POST">>, Req) ->
	{ok, Body, Req2} = cowboy_req:body_qs(Req),
	_Foo = proplists:get_value(<<"foo">>, Body),
	cowboy_req:reply(200, [
		{<<"content-type">>, <<"text/plain; charset=utf-8">>}
	], <<"ohai">>, Req);

process_req(_, _, Req) ->
	%% Method not allowed.
	cowboy_req:reply(405, Req).

Using erlydtl with Cowboy

Erlydtl seems to be the most popular Erlang templating library, and using it with Cowboy is fairly simple; but doesn’t seem to be terribly well documented.

As always, I’m assuming you’re using erlang.mk and know how to set up a Cowboy project. First, you need to add erlydtl as a dependency in your Makefile:

PROJECT = cowboy_stormpath
DEPS = cowboy erlydtl
include erlang.mk

and as a dependency in your .app.src:

{application, cowboy_erlydtl, [
    {description, ""},
    {vsn, "0.1.0"},
    {id, "git"},
    {modules, []},
    {registered, []},
    {applications, [
        kernel,
        stdlib,
        cowboy,
        erlydtl
    ]},
    {mod, {cowboy_erlydtl_app, []}},
    {env, []}
]}.

Next, create a folder called “templates” in your project root, any .dtl files in here will be compiled when you run “make app”:

<html><body>Your favourite Smurf is {{ smurf_name }}.</body></html>

If you look in ebin/ you should see a file named smurfin_dtl.beam (or whatever), this is the compiled version of your template. Finally, you need a handler to render the template:

-module(smurf_handler).
-behaviour(cowboy_http_handler).

-export([init/3]).
-export([handle/2]).
-export([terminate/3]).

-record(state, {}).

init(_, Req, _Opts) ->
    {ok, Req, #state{}}.

handle(Req, State=#state{}) ->
    {ok, Body} = smurfin_dtl:render([{smurf_name, "Smurfette"}]),
    {ok, Req2} = cowboy_req:reply(200, [{<<"content-type">>, <<"text/html">>}], Body, Req),
    {ok, Req2, State}.

terminate(_Reason, _Req, _State) ->
    ok.

and add a route:

    Dispatch = cowboy_router:compile([
        {'_', [
            {"/smurfin", smurf_handler, []}
        ]}
    ]),

et voila, you’ve rendered your first template!

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),
    notify_players(State),
    {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}] ->
            State
    end.

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

terminate(_Reason, _StateName, _State) ->
    dets:close(t3_game_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 ->
                    notify_players(NewState),
                    {next_state, p2_turn, NewState}
            end
    end,
    save_game_state(NewState),
    Res.

save_game_state(State = #state{game_id = GameId}) ->
    ok = dets:insert(t3_game_state, {GameId, State}),
    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.

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

Game FSM

Following on from Part 1, and Part 2, we’re now going to take a look at the game itself.

My first attempt at modeling the game state was using an array of arrays:

-record(state, {p1, p2, board=[
    ['_', '_', '_'],
    ['_', '_', '_'],
    ['_', '_', '_']
]).

but the lack of mutation makes that awkward to work with. My second draft used a map, keyed by {row, column} tuples:

-record(state, {p1, p2, board=#{
    {1,1} => '_', {1,2} => '_', {1,3} => '_',
    {2,1} => '_', {2,2} => '_', {2,3} => '_',
    {3,1} => '_', {3,2} => '_', {3,3} => '_'
}}).

but that blew up when being serialized to send to the client, due to the tuple keys. I could have re-formatted it before sending, but it seemed like less hassle to use the one model. So the final version just used bitstring keys:

-record(state, {p1, p2, board=#{
    <<"1,1">> => '_', <<"1,2">> => '_', <<"1,3">> => '_',
    <<"2,1">> => '_', <<"2,2">> => '_', <<"2,3">> => '_',
    <<"3,1">> => '_', <<"3,2">> => '_', <<"3,3">> => '_'
}}).

When the game is started:

init(Args) ->
    io:format("New game started: ~p~n", [Args]),
    [{P1, P2, GameId}] = Args,
    true = gproc:reg({n, l, GameId}),
    State = #state{p1 = P1, p2 = P2},
    P1 ! {your_turn, State#state.board},
    P2 ! {wait, State#state.board},
    {ok, p1_turn, State}.

It registers itself with gproc using the game id, and notifies the players of whose turn it is. There are only two states p1_turn and p2_turn:

p1_turn({play, P1, Cell}, State = #state{p1 = P1}) ->
    NewState = play(Cell, State, 'O'),
    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 ->
                    notify_players(NewState#state.p2, NewState#state.p1, NewState#state.board),
                    {next_state, p2_turn, NewState}
            end
    end.

play(Cell, State, Symbol) ->
    '_' = maps:get(Cell, State#state.board),
    State#state{board = maps:update(Cell, Symbol, State#state.board)}.

notify_players(Play, Wait, Board) ->
    Play ! {your_turn, Board},
    Wait ! {wait, Board}.

game_won(Win, Lose, Board) ->
    Win ! {you_win, Board},
    Lose ! {you_lose, Board}.

game_drawn(P1, P2, Board) ->
    P1 ! {draw, Board},
    P2 ! {draw, Board}.

First we check if it was a winning move, or a draw; in either case, the game is over and the process stops normally. As it was registered as a “transient” process, it will not be restarted. Otherwise it becomes the other player’s turn. In all cases, the updated board is sent to the client.

While this is a working implementation of multi-player game of tic-tac-toe, there are still a lot of rough edges. For example, if the FSM crashes it will be restarted but the game state will be lost. An improvement would be to store it (in an ETS table, or Mnesia, or even something like Riak) after successfully processing a message. It also relies on pids in a few places, that could change if a process is restarted; it would be better to look them up in gproc using a more stable identifier. It’s also possible that the websocket connection would be interrupted, and the client would need to resync with the server.

Using Lager with Cowboy

Lager is a popular logging framework for Erlang applications. To get it working with Cowboy (assuming you’re using erlang.mk), you need to add it as a dependency to your Makefile:

DEPS = cowboy lager

and then add the parse transform to the options for erlc (as shown here):

include erlang.mk
ERLC_OPTS += +'{parse_transform, lager_transform}'

(alternatively, you could add a compile header to each file that needs it). You also need to add lager to your app.src file:

    {applications, [
        kernel,
        stdlib,
        cowboy,
        lager
    ]},
]}.

so that it is started as part of your release. You can find an example repo here.

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

In Part 1 we dealt with session management, now we’ll take a look at starting a new game.

First, we send a message from the client:

newGameBtn.onclick = function() {
    var msg = JSON.stringify({type: 'new_game', sessionId: sessionId});
    send(msg);
    newGameBtn.disabled = true;
    clearBoard();
    updateStatus('Waiting to join game...');
};

and handle that on the other side of the websocket:

websocket_handle({text, Json}, Req, State) ->
    Msg = jiffy:decode(Json, [return_maps]),
    Resp = validate_session(Msg, fun() ->
        Type = maps:get(<<"type">>, Msg),
        handle_message(Type, Msg)
    end),
    {reply, make_frame(Resp), Req, State};

validate_session(Msg, Fun) ->
    SessionId = maps:get(<<"sessionId">>, Msg),
    case gen_server:call(t3_session_manager, {validate_session, SessionId}) of
        ok -> Fun();
        invalid_session -> #{type => <<"error">>, msg=> <<"invalid_session">>}
    end.

handle_message(<<"new_game">>, Msg) ->
    SessionId = maps:get(<<"sessionId">>, Msg),
    start_new_game(SessionId);

start_new_game(_SessionId) ->
    Res = try
         gen_server:call(t3_match_maker, {find_game}, 30000)
    catch
        exit:{timeout,_} -> timeout
    end,
    case Res of
        {ok, GameId} -> #{type => <<"new_game">>, id => GameId};
        timeout -> #{type => <<"no_game_available">>}
    end.

We decode the message, and validate the session; then we need to find a game, a task we delegate to the “match maker”, another gen_server. It’s possible a game won’t be found in time, so we need to handle timeouts too.

The match maker is pretty simple, like the session manager, but might be more interesting in a real app:

handle_call({find_game}, From, State) ->
    case find_game(From, State) of
        {ok, GameId, NewState} -> {reply, {ok, GameId}, NewState};
        {wait, NewState} -> {noreply, NewState}
    end;

find_game(From, #state{waiting=[]}) ->
    {wait, #state{waiting=[From]}};

find_game({P2,_}, #state{waiting=[From|Rest]}) ->
    GameId = uuid:uuid_to_string(uuid:get_v4(), binary_standard),
    {P1,_} = From,
    {ok, _Pid} = supervisor:start_child(t3_game_sup, [{P1, P2, GameId}]),
    gen_server:reply(From, {ok, GameId}),
    {ok, GameId, #state{waiting=Rest}}.

If someone is waiting, then we start a new game; otherwise, the pid of the websocket gets pushed onto the queue (this should probably be a more stable reference, like a user id, and we would look up the pid. We also don’t handle the case that a queued socket is closed, or times out waiting).

We need to add two more items to our supervision tree:

init([]) ->
    Procs = [
        {t3_session_manager, {t3_session_manager, start_link, []}, permanent, 5000, worker, [t3_session_manager]},
        {t3_match_maker, {t3_match_maker, start_link, []}, permanent, 5000, worker, [t3_match_maker]},
        {t3_game_sup, {t3_game_sup, start_link, []}, permanent, 5000, supervisor, [t3_game_sup]}
    ],
    {ok, {{one_for_one, 1, 5}, Procs}}.

The game supervisor is also very simple:

init([]) ->
    Procs = [{t3_game_fsm, {t3_game_fsm, start_link, []}, transient, 5000, worker, [t3_game_fsm]}],
    {ok, {{simple_one_for_one, 5, 10}, Procs}}.

The important thing to note is the “simple one-for-one” restart strategy, where children are added dynamically, on demand.

We’ll look at the game FSM in more detail in Part 3, but when started it notifies both players:

init(Args) ->
    io:format("New game started: ~p~n", [Args]),
    [{P1, P2, GameId}] = Args,
    true = gproc:reg({n, l, GameId}),
    State = #state{p1 = P1, p2 = P2},
    P1 ! {your_turn, State#state.board},
    P2 ! {wait, State#state.board},
    {ok, p1_turn, State}.

Finally, we need to handle some new messages on the client:

    } else if (msg.type === 'new_game') {
        gameId = msg.id;
        updateStatus('New game!');
    } else if (msg.type === 'your_turn') {
        updateStatus('Your turn!');
        updateBoard(msg.data);
        enableBoard();
    } else if (msg.type === 'wait') {
        updateBoard(msg.data);
        updateStatus('Waiting for other player...');
    }

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

Tic-tac-toe (or noughts and crosses) is a good example project, as the game itself is pretty simple and won’t become a distraction. I’m going to try and build a version using Erlang, and more specifically Cowboy: an http server/framework.

I’m going to assume you know the basics of setting up a new project, and skip straight to the more interesting bits. The goal is to create a multi-player, websockets & html based version of tic-tac-toe. As it’s turn-based, we avoid a lot of the really thorny problems of “real-time” multi-player games.

My first step was to create a session as soon as the socket is opened:

socket.onopen = function () {
    socket.send('new_session')
}

The backend then needs to handle this frame. I decided to add a session manager as a gen_server:

-record(state, {sessions=#{}}).

handle_call(new_session, _From, State) ->
    SessionId = uuid:uuid_to_string(uuid:get_v4(), binary_standard),
    NewState = update_session(SessionId, State),
    {reply, {ok, SessionId}, NewState};

update_session(SessionId, State) ->
    Expiry = half_an_hour_from_now(),
    prune_expired_sessions(State#state{sessions=maps:put(SessionId, Expiry, State#state.sessions)}).

half_an_hour_from_now() ->
    Now = calendar:universal_time(),
    calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(Now) + (30 * 60)).

prune_expired_sessions(State) ->
    SessionIds = maps:keys(State#state.sessions),
    {_, ExpiredSessions} = lists:partition(fun(S) -> session_valid(S, State#state.sessions) end, SessionIds),
    State#state{sessions=maps:without(ExpiredSessions, State#state.sessions)}.

session_valid(SessionId, Sessions) ->
    case maps:find(SessionId, Sessions) of
        error -> false;
        {ok, Expires} ->
            Expires > calendar:universal_time()
    end.

In a more realistic app, you would probably want to perform some authentication here, and use a datastore of some kind; but we’ll just create a new session and return the id. We also need to add the new server to the supervision tree:

init([]) ->
    Procs = [ 
        {t3_session_manager, {t3_session_manager, start_link, []}, permanent, 5000, worker, [t3_session_manager]}
    ],  
    {ok, {{one_for_one, 1, 5}, Procs}}.

We can then call the server from the ws handler:

websocket_handle({text, <<"new_session">>}, Req, State) ->
    Resp = start_new_session(),
    {reply, make_frame(Resp), Req, State};

start_new_session() ->
    {ok, SessionId} = gen_server:call(t3_session_manager, new_session),
    #{type => <<"new_session">>, sessionId => SessionId}.

make_frame(Msg) ->
    Json = jiffy:encode(Msg),
    {text, Json}.

We can call the server using it’s module name, rather than a pid, because it registered itself when starting:

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

(Watch out though, this effectively makes it a singleton, which could be a choke-point in a real app). We need to handle the response on the client:

socket.onmessage = function(ev) {
    console.log('Received data: ' + ev.data);
    var msg = JSON.parse(ev.data);

    if (msg.type === 'new_session') {
        sessionId = msg.sessionId;
        newGameBtn.disabled = false;
    }
}

And we’ll probably also need to be able to validate a session at some point:

handle_call({validate_session, SessionId}, _From, State) ->
    case session_valid(SessionId, State#state.sessions) of
        false ->
            {reply, {error, invalid_session}, State};
        true ->
            %% sliding expiry window
            NewState = update_session(SessionId, State),
            {reply, ok, NewState}
    end;

You can find the full source code here. In Part 2, we’ll look at starting a new game.

Hello world with Cowboy and Websockets

Cowboy is a web server/framework written in Erlang, that provides pretty seamless websocket support.

The “getting started” guide is very good, and I’m going to assume you’ve read it, and followed it up to the “cowboy setup” section.

We’re going to add a ws handler, rather than an http one, using the template:

make new t=cowboy_ws n=ws_handler

And wire it up in the app:

start(_Type, _Args) ->
    Dispatch = cowboy_router:compile([{'_', [
        {"/connect", ws_handler, []} 
    ]}]),
    Port = 8080,
    cowboy:start_http(my_http_listener, 100, [{port, Port}], [{env, [{dispatch, Dispatch}]}]),
    ping_pong_sup:start_link().

You could now build and run the release, and connect to a websocket by calling http://localhost:8080/connect. But it wouldn’t be very interesting… yet.

The next job is to add a web page that will call the server, in priv/static/index.html:

<html>
    <head>
        <title>Ping-Pong</title>
    </head>

    <body>
        <h1>Ping-Pong</h1>
    </body>

    <script>
var socket = new WebSocket('ws://localhost:8080/connect');
    
socket.onopen = function() {
    console.log('connected');
};
    </script>
</html>

and add a static handler to the dispatcher:

    Dispatch = cowboy_router:compile([{'_', [
        {"/", cowboy_static, {priv_file, ping_pong, "static/index.html"}},
        {"/connect", ws_handler, []} 
    ]}]),

Still not hugely exciting, but if you look in the js console in your browser you should be connected to a websocket! We can now send a message to the server:

socket.onopen = function() {
    console.log('connected');
    send('ping');
};

function send(data) {
    console.log('Sending data: ' + data);
    socket.send(data);
}

and return the standard response:

websocket_handle({text, <<"ping">>}, Req, State) ->
    Reply = {text, <<"pong">>},
    {reply, Reply, Req, State};

which we can listen for on the client:

socket.onmessage = function(ev) {
    console.log('Received data: ' + ev.data);
};

If all that went well, then we can liven things up by adding some json into the mix. First, add jiffy as a dependency (other json libraries are available!), then update the js to send some json instead:

socket.onopen = function() {
    console.log('connected');
    var msg = {type: 'ping', count: 1};
    send(JSON.stringify(msg));
};

and handle that on the server:

websocket_handle({text, Json}, Req, State) ->
    Map = jiffy:decode(Json, [return_maps]),
    Count = maps:get(<<"count">>, Map),
    Reply = #{type => <<"pong">>, count => Count + 1},
    {reply, {text, jiffy:encode(Reply)}, Req, State};

Just remember that the socket receives the json as text, you need to parse it!

socket.onmessage = function(ev) {
    console.log('Received data: ' + ev.data);
    var msg = JSON.parse(ev.data);
}

An alternative to parsing the json is to use pattern matching:

websocket_handle({text, <<"{\"type":\"ping\",\"count\":", Count:1/binary, "}">>}, Req, State) ->
    Reply = #{type => <<"pong">>, count => list_to_integer(binary_to_list(Count)) + 1}, 
    {reply, {text, jiffy:encode(Reply)}, Req, State};

but in this case, it has the disadvantage that the length of Count has to be specified.

You can find the source code for these examples here, and there’s another example in the Cowboy repo.