Building a chess server in Rails 5 with ActionCable-powered WebSockets

The first beta of Rails 5 was released recently. The biggest new feature is Action Cable, which provides support for implementing WebSockets with a pair of libraries in JavaScript (for the client) and Ruby (for the server). To explore the possibilities of this new paradigm in Rails, I built a simple chess server that allows two people to play a live game of chess against each other.

WebSockets are a convenient way to stream data between the client and server, making it easy to build apps that require real-time message passing. A chat room is the usual example of such an app: without anyone having to refresh the page, you want a message sent from one user to appear for all other connected users. In the past, you might implement this by having each client poll the server for new messages. WebSockets lets you replace polling with two-way channels that stream messages to where they're needed, as soon as they're created, avoiding the overhead and latency of continuous polling over HTTP.

Why not chess?

Instead of a chat room, I thought it'd be fun to cut my teeth on Action Cable by building a live chess server. The idea was to allow two (or more) players to connect to the server, get paired automatically, and then play a game of chess against each other, streaming each player's moves to their opponent over an Action Cable WebSocket channel.

In this article, I'll gloss over most of the chess-specific code and just focus on the Action Cable plumbing. If you're interested in the chess stuff, you can find the code I used to create the board and enforce the rules here. I used chessboard.js for the board itself, and chess.js to detect legal moves and know when the game is over.

Setting up

Before you can use Action Cable in a new Rails 5 app, you need to initialize the JavaScript client and mount the Action Cable server. The code to do all of this is already generated, you just need to uncomment it in the following files:

# app/assets/javascripts/cable.coffee
# ...

# @App ||= {}
# App.cable = ActionCable.createConsumer()
# config/routes.rb
Rails.application.routes.draw do
  # mount ActionCable.server => "/cable"
end

We'll also create a root page, controller and route:

rails g controller welcome
# config/routes.rb
Rails.application.routes.draw do
  root to: "welcome#index"
  mount ActionCable.server => "/cable"
end
<!-- app/views/welcome/index.html.erb -->
<div id="chessboard" style="width: 400px"></div>
<div id="messages"></div>

Identifying connections

Once two players are connected and ready to play a game, how do we keep track of who's who? When Player A makes a move, we need to send it to Player B's client so that their board can be updated.

When a client connects to our Action Cable server, a connection object will be instantiated. We can give each connection a unique identifier, which gives us a way to reference specific clients in the channel classes we'll write later.

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :uuid

    def connect
      self.uuid = SecureRandom.uuid
    end
  end
end

We'll be able to access the uuid identifier via an instance variable by the same name within our channels. You can use any identifier you want - commonly this will be the id of the logged-in user, but we're using a random UUID since we don't have user accounts.

Creating a channel and subscription

Channels are similar to controllers in the Action Cable paradigm. They have a set of actions that respond to client requests, including subscribing to, and unsubscribing from the channel. Each channel is paired with a client-side subscription. The subscription object manages the client half of the WebSocket connection: sending messages to and receiving messages from the channel.

To generate a server-side channel and a corresponding client-side subscription, we'll run rails g channel game, which creates app/channels/game_channel.rb and app/assets/javascripts/channels/game.coffee.

To start, we'll implement the channel's #subscribe method, using the connection UUID we set up earlier:

# app/channels/game_channel.rb
class GameChannel < ApplicationCable::Channel
  def subscribed
    stream_from "player_#{uuid}"
  end
end

If you start the server and load the app in your browser, you can see the connection and subscription getting created in the logs:

Started GET "/" for ::1 at 2015-12-27 18:35:28 -0600
Processing by WelcomeController#index as HTML
  Rendered welcome/index.html.erb within layouts/application (1.5ms)

Registered connection (43b6fc17d18a9bcd513230ea1da8b7c7)
GameChannel is transmitting the subscription confirmation
GameChannel is streaming from player_43b6fc17d18a9bcd513230ea1da8b7c7

Pairing players together

When a new player arrives, we'll check to see if anybody is already connected and waiting to play. If so, we'll pair those players together and start a game. If not, we'll store an indiciator that the player is waiting for a game (called a "seek"), and connect them to the next player who arrives.

The Seek model handles this by pushing and popping player UUIDs onto a Redis set. If the set already contains a UUID, the model will start a game between those two players.

We'll hook this up to our app in our GameChannel#subscribed callback.

class GameChannel < ApplicationCable::Channel
  def subscribed
    stream_from "player_#{uuid}"
    Seek.create(uuid)
  end
end

What happens if a player arrives and immediately leaves before they can be matched with an opponent? We want to make sure to remove their seek so that the next player isn't paired with someone who's no longer there. This is a good use case for the channel's #unsubscribed callback, which gets triggered when the server detects that a client is no longer connected.

class GameChannel < ApplicationCable::Channel
  # ...

  def unsubscribed
    Seek.remove(uuid)
  end
end

On the client side, let's print a message letting the player know that they're waiting to be matched with an opponent. We can do this in our subscription's connected callback:

# app/assets/javascripts/channels/game.coffee
App.game = App.cable.subscriptions.create "GameChannel",
  connected: ->
    @printMessage("Waiting for opponent...")

  printMessage: (message) ->
    $("#messages").append("<p>#{message}</p>")

Starting a game

The Seek.create method (which we call in GameChannel#subscribed) will start a new game if it's able to successful pair two players together. We'll use a Game model to handle the logic for starting and ending games, as well as communicating moves between the players.

class Game
  def self.start(uuid1, uuid2)
    white, black = [uuid1, uuid2].shuffle

    ActionCable.server.broadcast "player_#{white}", {action: "game_start", msg: "white"}
    ActionCable.server.broadcast "player_#{black}", {action: "game_start", msg: "black"}

    REDIS.set("opponent_for:#{white}", black)
    REDIS.set("opponent_for:#{black}", white)
  end
end

The key part of this method is the pair of ActionCable.server.broadcast calls. That's how we send messages to the "player_#{uuid}" channels to let the players know that a game has begun, and what color they're playing as (remember we had the clients subscribe to those channels with the stream_from method in GameChannel#subscribed).

The structure of the messages is arbitrary - you can use a hash or a string. For consistency, we'll use the structure of {action: "", msg: ""}.

In Game.start, we'll also store the opponent pairs in Redis. This way, when a player makes a move, we'll be able to look up who their opponent is so that we can send them a message containing the move data.

Now that we're sending messages across channels, we need to add some code that allows the clients to receive and process those messages. Let's go back to our subscription class (defined in game.coffee) and add a received callback.

App.game = App.cable.subscriptions.create "GameChannel",
  # ...

  received: (data) ->
    switch data.action
      when "game_start"
        App.board.position("start")
        App.board.orientation(data.msg)
        @printMessage("Game started! You play as #{data.msg}.")

The received callback is run any time the client receives a message from the channel. Here we're just switching on the action attribute of the message. If it's a message signaling the start of a game, we set up and activate the board and print a message.

Sending and receiving moves

Now that two players have been paired together and their boards initialized, we can start playing chess! Everything up to this point has been mostly setup, but this is where things start to get cool. Sending data from one client to another in real-time is what WebSockets are all about.

The client-side subscription object (stored in App.game) has a perform method that we can use to pass actions and data to our server-side GameChannel class. We'll use this to send moves from one client's board to the server, and from the server to the other client's board.

# app/assets/javascripts/board.coffee
$ ->
  App.chess = new Chess()

  cfg =
    onDrop: (source, target) =>
      move = App.chess.move
        from: source
        to: target
        promotion: "q"

      if (move == null)
        # illegal move
        return "snapback"
      else
        App.game.perform("make_move", move)

  App.board = ChessBoard("chessboard", cfg)

This is part of the code for our board object. The onDrop callback is triggered when a user attempts to place a piece somewhere on the board. In the callback we check to make sure the move is legal, returning the piece to its original position if not. If the move is legal, we call the subscription's perform method with the action we want to call (make_move) and the data we want to send.

The first argument in the perform method must match an action defined in the subscription's corresponding channel. Let's create that now:

class GameChannel < ApplicationCable::Channel
  # ...

  def make_move(data)
    Game.make_move(uuid, data)
  end
end

The action receives the data from the client, and delegates it to our Game model. Note that the uuid variable identifying the active connection is available to all methods in the channel instance.

class Game
  # ...

  def self.make_move(uuid, data)
    opponent = opponent_for(uuid)
    move_string = "#{data["from"]}-#{data["to"]}"

    ActionCable.server.broadcast "player_#{opponent}", {action: "make_move", msg: move_string}
  end
end

Our Game::make_move method simply parses the move data, finds the opponent for the given UUID, and sends the move data to the channel via ActionCable.server.broadcast. We'll use the same stucture as our other methods and use an "action" and "msg" key.

This will send a message to the opponent's client. So how do they receive it and do something with it?

App.game = App.cable.subscriptions.create "GameChannel",
  # ...

  received: (data) ->
    switch data.action
      when "game_start"
        # ...
      when "make_move"
        [source, target] = data.msg.split("-")

        App.board.move(data.msg)
        App.chess.move
          from: source
          to: target
          promotion: "q"

We just extend our client-side subscription's received callback to handle a new type of message. When a move is received, we'll update the board object (which supplies the visual representation of the game) and the App.chess object (which models the actual rules of the game).

Ending the game

If a game reaches checkmate, our App.chess object will realize it and the game will end because neither player has any legal moves. But what if a player disconnects? We don't want to leave their opponent hanging forever.

class GameChannel < ApplicationCable::Channel
  # ...

  def unsubscribed
    Seek.remove(uuid)
    Game.forfeit(uuid)
  end
end

Remember the #unsubscribed hook we wrote when setting up seeks? Let's have it also forfeit any games in progress when a user disconnects.

class Game
  # ...

  def self.forfeit(uuid)
    if winner = opponent_for(uuid)
      ActionCable.server.broadcast "player_#{winner}", {action: "opponent_forfeits"}
    end
  end
end

Pretty simple: we just check to see if the player had an opponent (ie: were they playing a game instead of waiting for a game) and, if so, send them a message saying that their opponent forfeited.

Now we just need to add the client-side code to handle this new type of message.

App.game = App.cable.subscriptions.create "GameChannel",
  # ...

  received: (data) ->
    switch data.action
      when "game_start"
        # ...
      when "make_move"
        # ...
      when "opponent_forfeits"
        @printMessage("Opponent forfeits. You win!")

Going further

I wanted to give an interesting example of what's possible with Rails 5 Action Cables. However, our chess server still lacks a lot of features, like...

If you're looking for an excuse to play with Action Cables yourself, why not fork the project and try adding one?

Jan 24, 2016 • rails (2), programming (1)