Introduction

I've been meaning to learn Erlang for a while, and I decided that for my nontrivial learning project (I always teach myself a new language on something nontrivial -- hello world only takes you so far), I would go for a network game of some kind. However, I couldn't find a decent Erlang socket programming tutorial pitched at the level I needed. So I decided to write one.

I'm aiming this at people who have a fairly strong programming background, although not necessarily in functional programming. You'll also need to understand the basics of TCP and sockets and be somwhat familiar with Erlang for this to make much sense. I'd recommend running through the Getting Started With Erlang tutorial that comes with the Erlang docs.

This tutorial follows the basics steps I took going from nothing to Bogochat. It takes an iterative approach, starting simple and building up to more complex systems.

Changes

2007-03-16

Thanks to the people on the erlang-talk mailing list who provided comments and suggestions relating to the first version of this tutorial.

Iteration 1: Echo

The first step was to listen on a socket, accept an incoming connection and do something with it. TCP socket functions live in gen_tcp and the usual listen, accept, send and recv functions are available. The following is a simple echo server that accepts only one connection and echoes everything it gets back to the sender.

-module(echo).
-export([listen/1]).

%% TCP options for our listening socket.  The initial list atom
%% specifies that we should receive data as lists of bytes (ie
%% strings) rather than binary objects and the rest are explained
%% better in the Erlang docs than I can do here.

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

%% Listen on the given port, accept the first incoming connection and
%% launch the echo loop on it.

listen(Port) ->
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    {ok, Socket} = gen_tcp:accept(LSocket),
    do_echo(Socket).

%% Sit in a loop, echoing everything that comes in on the socket.
%% Exits cleanly on client disconnect.

do_echo(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            gen_tcp:send(Socket, Data),
            do_echo(Socket);
        {error, closed} ->
            ok
    end.

Iteration 2: Multiclient echo

The first echo server worked, but it only let one person on at a time and had to be restarted afterwards. The next step is to make it accept a bunch of clients at once. The simplest way to do this is to launch a new process for each client and then go back to waiting for new clients on the listen socket.

-module(multiecho).
-export([listen/1]).

%% TCP options for our listening socket.  The initial list atom
%% specifies that we should receive data as lists of bytes (ie
%% strings) rather than binary objects and the rest are explained
%% better in the Erlang docs than I can do here.

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

%% Listen on the given port, accept the first incoming connection and
%% launch the echo loop on it.

listen(Port) ->
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    do_accept(LSocket).

%% The accept gets its own function so we can loop easily.  Yay tail
%% recursion!

do_accept(LSocket) ->
    {ok, Socket} = gen_tcp:accept(LSocket),
    spawn(fun() -> do_echo(Socket) end),
    do_accept(LSocket).

%% Sit in a loop, echoing everything that comes in on the socket.
%% Exits cleanly on client disconnect.

do_echo(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            gen_tcp:send(Socket, Data),
            do_echo(Socket);
        {error, closed} ->
            ok
    end.

Spawning a fun() rather than an existing function means you don't have to export the funtions you want to spawn and clutter up your namespace.

Iteration 3: Very basic chat

Now we are ready to do some multiplayer stuff. Since we need to send data to socket other than the one we're receiving from, we need a client manager process that keeps track of connections and disconnections.

The accept loop tells the client manager that a new client has joined and the client handler loop sends data and disconnect notifications.

-module(basicchat).
-export([listen/1]).

%% TCP options for our listening socket.  The initial list atom
%% specifies that we should receive data as lists of bytes (ie
%% strings) rather than binary objects and the rest are explained
%% better in the Erlang docs than I can do here.

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

%% Listen on the given port, accept the first incoming connection and
%% launch the echo loop on it.  This also needs to launch the
%% client_manager proces, since it's our server's entry point.

listen(Port) ->
    Pid = spawn(fun() -> manage_clients([]) end),
    register(client_manager, Pid),
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    do_accept(LSocket).

%% The accept gets its own function so we can loop easily.  Yay tail
%% recursion!  We also need to let client_manager know that there's a
%% new socket to manage.

do_accept(LSocket) ->
    {ok, Socket} = gen_tcp:accept(LSocket),
    spawn(fun() -> handle_client(Socket) end),
    client_manager ! {connect, Socket},
    do_accept(LSocket).

%% handle_client/1 replaces do_echo/1 because we now do everything
%% through the client_manager process.  Disconnects notify
%% client_manager that the socket is no longer open and data is sent
%% as-is to be distributed.

handle_client(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            client_manager ! {data, Data},
            handle_client(Socket);
        {error, closed} ->
            client_manager ! {disconnect, Socket}
    end.

%% Maintain a list of sockets, handle connect and disconnect messages
%% and distribute data between them.

manage_clients(Sockets) ->
    receive
        {connect, Socket} ->
            io:fwrite("Socket connected: ~w~n", [Socket]),
            NewSockets = [Socket | Sockets];
        {disconnect, Socket} ->
            io:fwrite("Socket disconnected: ~w~n", [Socket]),
            NewSockets = lists:delete(Socket, Sockets);
        {data, Data} ->
            send_data(Sockets, Data),
            NewSockets = Sockets
    end,
    manage_clients(NewSockets).

%% Send data to all sockets in the list.  This is done by constructing
%% a closure around gen_tcp:send and the data and then passing that to
%% lists:foreach/2 with the list of sockets.

send_data(Sockets, Data) ->
    SendData = fun(Socket) ->
                       gen_tcp:send(Socket, Data)
               end,
    lists:foreach(SendData, Sockets).

Iteration 4: Bogochat

If we want to deal with players on anything but the most basic of levels, we need to give the client manager some more information about them. This iteration gives each client a name and sends data only to active players other than the sender.

Note that the client manager process keeps all the player data, but the lower-level network handler knows only about sockets. The parser function, on the other hand, needs the whole player record so it can modify its behaviour depending on state and also change said state.

-module(bogochat).
-export([listen/1]).

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

-record(player, {name=none, socket, mode}).

%% To allow incoming connections, we need to listen on a TCP port.
%% This is also the entry point for our server as a whole, so it
%% starts the client_manager process and gives it a name so the rest
%% of the code can get to it easily.

listen(Port) ->
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    Pid = spawn(fun() -> maintain_clients([]) end),
    register(client_manager, Pid),
    do_accept(LSocket).

%% Accepting a connection gives us a connection socket with the
%% newly-connected client on the other end.  Since we want to accept
%% more than one client, we spawn a new process for each and then wait
%% for another connection on our listening socket.

do_accept(LSocket) ->
    case gen_tcp:accept(LSocket) of
        {ok, Socket} ->
            spawn(fun() -> handle_client(Socket) end),
            client_manager ! {connect, Socket};
        {error, Reason} ->
            io:fwrite("Socket accept error: ~s~n", [Reason])
    end,
    do_accept(LSocket).

%% All the client-socket process needs to do is wait for data and
%% forward it to the client_manager process which decides what to do
%% with it.  If the client disconnects, we let client_manager know and
%% then quietly go away.

handle_client(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            client_manager ! {data, Socket, Data},
            handle_client(Socket);
        {error, closed} ->
            client_manager ! {disconnect, Socket}
    end.

%% This is the main loop of the client_manager process.  It maintains
%% the list of "players" and calls the handler for client input.

maintain_clients(Players) ->
    io:fwrite("Players:~n"),
    lists:foreach(fun(P) -> io:fwrite(">>> ~w~n", [P]) end, Players),
    receive
        {connect, Socket} ->
            Player = #player{socket=Socket, mode=connect},
            send_prompt(Player),
            io:fwrite("client connected: ~w~n", [Player]),
            NewPlayers =  [Player | Players];
        {disconnect, Socket} ->
            Player = find_player(Socket, Players),
            io:fwrite("client disconnected: ~w~n", [Player]),
            NewPlayers = lists:delete(Player, Players);
        {data, Socket, Data} ->
            Player = find_player(Socket, Players),
            NewPlayers = parse_data(Player, Players, Data),
            NewPlayer = find_player(Socket, NewPlayers),
            send_prompt(NewPlayer)
    end,
    maintain_clients(NewPlayers).

%% find_player is a utility function to get a player record associated
%% with a particular socket out of the player list.

find_player(Socket, Players) ->
    {value, Player} = lists:keysearch(Socket, #player.socket, Players),
    Player.

%% delete_player returns the player list without the given player.  It
%% deletes the player from the list based on the socket rather than
%% the whole record because the list might hold a different version.

delete_player(Player, Players) ->
    lists:keydelete(Player#player.socket, #player.socket, Players).

%% Sends an appropriate prompt to the player.  Currently the only
%% prompt we send is the initial "Name: " when the player connects.

send_prompt(Player) ->
    case Player#player.mode of
        connect ->
            gen_tcp:send(Player#player.socket, "Name: ");
        active ->
            ok
    end.

%% Sends the given data to all players in active mode.

send_to_active(Prefix, Players, Data) ->
    ActivePlayers = lists:filter(fun(P) -> P#player.mode == active end,
                                 Players),
    lists:foreach(fun(P) -> gen_tcp:send(P#player.socket, Prefix ++ Data) end,
                  ActivePlayers),
    ok.

%% We don't really do much parsing, but that will probably change as
%% more features are added.  Currently this handles naming the player
%% when he first connects and treats everything else as a message to
%% send.

parse_data(Player, Players, Data) ->
    case Player#player.mode of
        active ->
            send_to_active(Player#player.name ++ ": ",
              delete_player(Player, Players), Data),
            Players;
        connect ->
            UPlayer = Player#player{name=bogostrip(Data), mode=active},
            [UPlayer | delete_player(Player, Players)]
    end.

%% Utility methods to clean up the name before we apply it.  Called
%% bogostrip rather than strip because it returns the first continuous
%% block of non-matching characters rather stripping matching
%% characters off the front and back.

bogostrip(String) ->
    bogostrip(String, "\r\n\t ").

bogostrip(String, Chars) ->
    [Stripped|_Rest] = string:tokens(String, Chars),
    Stripped.

Conclusion

The next step is to build this into a gen_server app, but that can wait for another day. Please send feedback to firxen at gmail.