Creating a Game Lobby System in Phoenix with Websockets

by QLer Alex Jensen

With the Phoenix web framework entering the scene, many people are amazed by how well it handles websockets and how easy it is to create a “hello world” chat app. Given that websockets are treated as first-class citizens in Phoenix, I thought it was worth exploring a more challenging problem than the typical chat app. In this post, we’ll look at how to harness the power of Phoenix to create a game lobby system, complete with invitations.

Be wary that Phoenix and Elixir are still under heavy development and prone to backwards-incompatable changes. The code in this post will be using Elixir 1.2.0 and Phoenix 1.1.3.

Authentication

To get the ball rolling, we need some logged in users to play with. I followed this post to set up users with basic authentication. To handle authentication through websockets, I’ll be using Phoenix.Token.

We need to give the user a token they can use to prove that they are who they say they are. To accomplish this, I used the <meta> tag approach to store a token. Inside your application layout, add the following:

# templates/layout/app.html.eex
<head>
...
<%= if @current_user do %>
<%= tag :meta, name: "channel_token",
content: Phoenix.Token.sign(@conn, "user", @current_user.id) %>
<% end %>
...
</head>

To verify this token is valid when the user connects through their websocket, replace the connect method inside of web/channels/user_socket.ex with:

# web/channels/user_socket.ex
alias MyApp.{Repo, User}
def connect(%{"token" => token}, socket) do
# 1 day = 86400 seconds
case Phoenix.Token.verify(socket, "user", token, max_age: 86400) do
{:ok, user_id} ->
socket = assign(socket, :current_user, Repo.get!(User, user_id))
{:ok, socket}
{:error, _} ->
:error
end
end

This will pull the user’s ID out of the token we handed them and assign that user to the socket, allowing us to reference the user later on.

To connect to the websocket from the front-end, we’ll grab the token from the meta tag and use it to establish a Socket connection when connecting to the back-end. Add this code to the generated socket.js file:

// web/static/js/socket.js
var token = $('meta[name=channel_token]').attr('content');
var socket = new Socket('/socket', {params: {token: token}});
socket.connect();

Connecting to the lobby room

On our server, once a user logs in to the site, we want them to see who else is online to play with. Let’s start by making a lobby channel that all users can join and use to talk to potential opponents.

# web/channels/user_socket.ex
channel "game:lobby", MyApp.LobbyChannel

# new file
# web/channels/lobby_channel.ex
defmodule MyApp.LobbyChannel do
use MyApp.Web, :channel

def join("game:lobby", _payload, socket) do
{:ok, socket}
end
end

And now to connect with the front-end:

// web/static/js/socket.js
var lobby = socket.channel('game:lobby');
lobby.join().receive('ok', function() {
console.log('Connected to lobby!');
});

Viewing the online users

Now that users can log in to the websocket and connect to the lobby, they will need to be able to view other online users and invite them. Accomplishing this can be pretty challenging since Elixir is a functional language and cannot hold state. To accomplish this, we’ll be using the GenServer to have a separate process running to simulate holding state for us. A feature like this may one day be included in Phoenix, but this is what we need to do for now. A huge shoutout to the authors of this Phoenix Trello project, from which I learned this technique.

This is the code we’ll need to make our lobby work. I won’t go in depth how GenServer is working, but the high-level overview is that we can use this as a map that persists across requests.

# new file
# web/models/channel_monitor.ex
defmodule MyApp.ChannelMonitor do
use GenServer

def start_link(initial_state) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end

def user_joined(channel, user) do
GenServer.call(__MODULE__, {:user_joined, channel, user})
end

def users_in_channel(channel) do
GenServer.call(__MODULE__, {:users_in_channel, channel})
end

def user_left(channel, user_id) do
GenServer.call(__MODULE__, {:user_left, channel, user_id})
end

# GenServer implementation
def handle_call({:user_joined, channel, user}, _from, state) do
new_state = case Map.get(state, channel) do
nil ->
Map.put(state, channel, [user])
users ->
Map.put(state, channel, Enum.uniq([user | users]))
end

{:reply, new_state, new_state}
end

def handle_call({:users_in_channel, channel}, _from, state) do
{:reply, Map.get(state, channel), state}
end

def handle_call({:user_left, channel, user_id}, _from, state) do
new_users = state
|> Map.get(channel)
|> Enum.reject(&(&1.id == user_id))

new_state = Map.update!(state, channel, fn(_) -> new_users end)

{:reply, new_state, new_state}
end
end

Now we have to tell Phoenix to boot up with the ChannelMonitorby adding it to the start function that is executed when Phoenix starts. After making this change, you will need to restart your server to get the process running.

# lib/my_app.ex
def start(_type, _args) do
...
children = [
...
worker(MyApp.ChannelMonitor, [%{}]),
]
end

Now that this is in place, we can use the ChannelMonitor inside our channels. Inside the LobbyChannel, replace the contents with the following code:

# web/channels/lobby_channel.ex
defmodule MyApp.LobbyChannel do
use MyApp.Web, :channel
alias MyApp.ChannelMonitor

def join("game:lobby", _payload, socket) do
current_user = socket.assigns.current_user
users = ChannelMonitor.user_joined("game:lobby", current_user)["game:lobby"]
send self, {:after_join, users}
{:ok, socket}
end

def terminate(_reason, socket) do
user_id = socket.assigns.current_user.id
users = ChannelMonitor.user_left("game:lobby", user_id)["game:lobby"]
lobby_update(socket, users)
:ok
end

def handle_info({:after_join, users}, socket) do
lobby_update(socket, users)
{:noreply, socket}
end

defp lobby_update(socket, users) do
broadcast! socket, "lobby_update", %{ users: get_usernames(users) }
end

defp get_usernames(nil), do: []
defp get_usernames(users) do
Enum.map users, &(&1.username)
end
end

What is all this code doing? The ChannelMonitor is a map of channel name keys to lists of User models. Every time we make and update to ChannelMonitor it will return that map, which we can use to look up the users in that channel. Because the values are lists of User models, we need to grab the username off each and send that list to the front-end. The two updates we need to handle for the ChannelMonitor are the starting and ending of connections, which we get through the join and terminate methods. Also note that we still have access to socket.assigns.current_user since we had assigned the current_user inside UserSocket#connect.

When we want to send messages from the server back through the websocket over a channel to every user, we use broadcast! socket, name_of_event, data. Here we’re sending a “lobby_update” event and handing every connected user the new list of currently connected users. Phoenix will raise an error if you try to use broadcast! from inside a channel join, because the socket has not finished joining. Using send self, {args} will allow you to send messages upon users joining, and we can pattern match on the args in handle_info, which will broadcast out to our users.

Accepting this on the front-end is fairly simple. Change our lobby code to listen for the “lobby_update” event and take in the data we sent from the back-end:

// web/static/js/socket.js
var lobby = socket.channel('game:lobby');
lobby.on('lobby_update', function(response) {
console.log(JSON.stringify(response.users));
});
lobby.join().receive('ok', function() {
console.log('Connected to lobby!');
});

Now we can watch our lobby fill up and shrink as users connect/disconnect from the server. You can log into multiple accounts using an incognito tab. The new tab will provide a different set of cookies so you can log into two users at once.

Inviting other users to play with you

We’ve got our list of online users. Now we want to play a game with one of them. Our game lobby will implement an invite/accept flow to start up a game from the lobby. We’ll need to set up the back-end to listen for an event when an invite gets sent and dispatch it to the correct person. We can do that with the following code:

# web/channels/lobby_channel.ex
def handle_in("game_invite", %{"username" => username}, socket) do
data = %{"username" => username, "sender" => socket.assigns.current_user.username }
broadcast! socket, "game_invite", data
{:noreply, socket}
end

You’ll notice there’s something a bit wrong with this method. We want to send an invite only to the user that should be receiving the invite, not broadcast it to every person in the lobby. The problem with this is that only ways to push out to the front-end are the send and broadcast methods. The send method requires the socket that we’re talking to, and we only have access to the sender’s socket here. To fix this, we use broadcast and define a custom handle_out to only send to the user we want to be sending to.

# web/channels/lobby_channel.ex
intercept ["game_invite"]
def handle_out("game_invite", %{"username" => username, "sender" => sender}, socket) do
if socket.assigns.current_user.username == username do
push socket, "game_invite", %{ username: sender}
end
{:noreply, socket}
end

intercept tells Phoenix to use our definition of handle_out in favor of the default broadcast for the event name specified. Here we’re taking in each socket the channel is connected to and doing whatever we want with it. Overall, we do nothing until we reach the user that is supposed to be receiving the invite, and send them a signal of who invited them to a game. To accept invites on the front-end, we’ll add the following code:

// web/static/js/socket.js
lobby.on('game_invite', function(response) {
console.log('You were invited to join a game by', response.username);
});
window.invitePlayer = function(username) {
lobby.push('game_invite', {username: username});
};

Now you can try this out by seeing your list of online users and using invitePlayer(‘other_user’) to send them an invite. The message should only be sent to the other player and not back to you.

Conclusion

In this post, we’ve created a lobby room in which we can see the current online users and send them invitations to play a game. We created this system utilizing Phoenix’s convenient websocket handling and saving state in a separate process. From here, you could create separate channels for users to play games on once they have been invited. Happy coding!

Want to read up on more great content? Subscribe to our blog below.

Technology news - Subscribe to the Quick Left blog

About Alex

Alex is a Software Developer at Quick Left. Fresh out of Turing and before that a high school grad, he’s proof that age is nothing but a number. In his spare time Alex likes to play chess. He took first place in a state tournament and was top 10 in Nationals for Blitz (a 5 minute game). Challenge him, we dare you.


Originally published at Quick Left.