Ruby
Article

Leaderboards on Rails

By Glenn Goodrich

Screenshot 2015-03-07 14.41.07

Recently, I had reason to create a Rails app that was a simple leaderboard. My constraints were as follows:

  • Accept a name and a score for a new entry on the board. If the name already exists, update the score with the new value.
  • Return a JSON representation for a supplied name, including rank and score.
  • Support HTML and JSON representation of all the entries on the leaderboard. Include the ability to specify page number (default to 0) and offset (default to 10).
  • Do not allow more than 100 records to be returned for a single request.
  • Allow for removal of a specified name from the leaderboard.

All in all, the requirements are pretty straightforward. Basically, allow the user to create, update, and remove entries on the leaderboard. Also, return the current entries, ordered by rank, in either HTML or JSON format.

A quick Google or two later, I knew I wanted to use Redis. This article really articulates how Redis’ features support a leaderboard quite well. Namely, the Redis data type Sorted Set was made for leaderboards. Here are some of the Sorted Set Commands that will be used:

  • ZADD: Add one or more members to the set with the specified scores. ZADD myzset 2 "two" 3 "three" will add 2 members.
  • ZREVRANGEBYSCORE: Return all the members in the leaderboard found between the supplied min and max values. This is used to show the members from highest to lowest.
  • ZSCORE and ZRANK allow the retrieval of members by score and rank, respectively.

I am not the only one that thinks Redis works well for this use case. Github user agoragames has created a Ruby gem to make it even easier. The leaderboard gem not only makes using Redis for leaderboards easy, it adds a TON of features on top. If there was a leaderboard for leaderboard gems, leaderboard would be at the top.

Application

In this article, I am going to show you a very simple, Rails-backed leaderboard. While some of the front-end behavior (i.e., paging) is Rails-specific, the leaderboard operations are not. You should, if you so desire, easily be able to take the leaderboard-specific bits out of this and use them in a Sinatra or Volt or Padrino application without any hassle. As you’ll see, I’ve placed all the code that touches the leaderboard into a set of service objects. I did this out of love.

The leaderboard itself could be for anything. I am going to use the Faker gem to just populate it with names. This allows us to see many entries in the board and demonstrate what happens when we add new or update existing entries. I’ll share the Ruby script that creates the entries when we get to that bit.

The code for this leaderboard app is here and a demonstration for the app is here.

I am not going to go through the setup of the application, as you can look at the code. It’s a pretty vanilla Rails app with the following gems added:

  • leaderboard: The gem we’re talking about here.
  • faker: The gem to generate fake/test values. It’s used here to generate the names for our members.
  • cells: “View Components for Rails”. Basically, view models for Rails. This is not necessary for the leaderboard, but I like them.
  • kaminari: A well-known pagination gem for Rails. The leaderboard will be paginated.

I also am using RSpec and PostgreSQL, so the necessary gems for those are in the Gemfile, too. The specs are not 100% complete, but they do cover the happy path. PostgreSQL is basically not being used at all, I am just too lazy to remove it. Next steps for this application would include authentication, and I’ll use PostgreSQL and Devise for that, more than likely.

Routes and Views

The routes for our leaderboard app are very simple:

Prefix Verb   URI Pattern                 Controller#Action
 not_found GET    /not_found(.:format)        errors#not_found
   entries GET    /entries(.:format)          entries#index
           POST   /entries(.:format)          entries#create
 new_entry GET    /entries/new(.:format)      entries#new
edit_entry GET    /entries/:id/edit(.:format) entries#edit
     entry GET    /entries/:id(.:format)      entries#show
           PATCH  /entries/:id(.:format)      entries#update
           PUT    /entries/:id(.:format)      entries#update
           DELETE /entries/:id(.:format)      entries#destroy
      root GET    /                           leaderboards#show

Basically, there is one leaderboard, so it gets a show method. The leaderboard is made up of entries, and Entry is a full Rails resource. Entries can be created with a POST to the collection route. All the entries can be obtained with a GET to the collection. Individual entries follow the standard Rails RESTful routes. I added a “Not Found” route just for completeness sake.

It’s important to note that the only real HTML view in the app is to show the leaderboard. Everything else will be done via AJAX. I am using Angular for that (the sound of 100s of ReactJS users guffawing). Finally, for styles and layout, I am using Zurb Foundation (the sound of 100s of Bootstrap users guffawing).

Boards Service

As previously mentioned, I have isolated all the leaderboard code. The Boards module has a couple of module-level methods to hold defaults:

module Boards
  DEFAULT_BOARD = 'ccleaders'

  def self.default_leaderboard
    Leaderboard.new(
      DEFAULT_BOARD,
      default_options,
      redis_connection: Redis.current
    )
  end

  def self.default_options
    Leaderboard::DEFAULT_OPTIONS.merge(
      page_size: 10
    )
  end
end

default_leaderboard returns the leaderboard used by the application. The service methods will default to this, unless they are provided a leaderboard explicitly. default_options holds the page size along with the default options from the leaderboard gem itself. You should click through and read about the various options of the leaderboard gem.

To meet the application requirements, we need to:

  • Get all the entries
  • Get a single entry
  • Create/Update entries
  • Delete an entry

The classes that do the work are explained below.

GetAllService

Get the entries of the leaderboard. Options can be supplied, such as page and page_size.

module Boards
  class GetAllService < Boards::Base
    def execute(options = {})
      leaderboard.leaders(
        page(options),
        page_size: page_size(options)
      )
    end

    private

    def page(options)
      options[:page] || 1
    end

    def page_size(options)
      options[:page_size] || 10
    end
  end
end

GetAllService calls the leaderboard.leaders method, passing in the page options. This will return the entries in the leaderboard. The leaderboard method is on the Boards::Base class and looks like:

class Base
  def leaderboard
    @leaderboard ||= Boards.default_leaderboard
  end

  def member_exists?(name)
    leaderboard.check_member?(name)
  end
end

I am only supporting a single leaderboard in this example, but it would be easy enough to handle multiple leaderboards

GetService

Get a single entry in the leaderboard based on name.

module Boards
  class GetService < Boards::Base
    def execute(options = {})
      return unless member_exists?(options[:name])
      leaderboard.score_and_rank_for(options[:name])
    end
  end
end

Man, the leaderboard gem makes this so simple. Quickly check to see if an entry exists (member_exists? is in the Boards::Base class), then return that entry’s score and rank.

UpdateService

Create or update an entry in the leaderboard for the given name and score. This will also return the page the entry is on based on the Leaderboard page size.

module Boards
  class UpdateService < Boards::Base
    def execute(entry_params)
      name = entry_params[:name]
      score = entry_params[:score].to_i
      leaderboard.rank_member(name, score)
      member = leaderboard.score_and_rank_for(name)
      member[:page] = leaderboard.page_for(name, leaderboard.page_size)
      member
    end
  end
end

Get the score and name from the parameters, then call rank_member. I added something extra to the end of this method to get the page that the new (or updated) entry is now on, based on the leaderboard page size. I use that to take the user to that page when the operation is complete.

Also, this class is used to create and update entries, so there is no CreateService.

DeleteService

Remove an entry from the leaderboard based on the supplied name.

module Boards
  class DeleteService < Boards::Base
    def execute(options = {})
      return unless member_exists?(options[:name])
      leaderboard.remove_member(options[:name])
    end
  end
end

Just a simple call to remove_member with the name. Easy peasy.

You may be wondering if I regret adding Service to the name of each of these classes. I do.

The controllers will consume these objects and methods to perform the actions based on the user requests.

Leaderboard Controller and View

The LeaderboardController has a single action: show. This action gets the entries for the current page of the leaderboard.

class LeaderboardsController < ApplicationController
  before_action :query_options

  def show
    @lb = Boards.default_leaderboard
    @entries = entry_service.execute(query_options)
    respond_to do |format|
      format.html do
        paginate
      end
      format.json do
        render json: @entries
      end
    end
  end

  private

  def query_options
    @limit = [params.fetch(:limit, 10).to_i, 100].min
    @page = params.fetch(:page, 1).to_i
    { page: @page, limit: @limit }
  end

  def paginate
    pager = Kaminari.paginate_array(
      @entries,
      total_count: @lb.total_members)

    @page_array = pager.page(@page).per(@limit)
  end

  def entry_service
    Boards::GetAllService.new
  end
end

The show method is pretty straightforward. It grabs the default leaderboard and calls the Boards::GetAllService to get the entries. If we are rendering HTML, the paginate method uses the incredibly useful Kaminari.paginate_array method. We’ll even get the total number of pages, since the leaderboard knows it’s total_members. I thought pagination was going to be more of a bugaboo than this, but the Ruby community has my back again.

The show view looks like this:

<h1><%= t('leaderboard.subtitle') %></h1>
<%= render_cell :leaderboard, :new_entry_form %>
<table style='width: 100%'>
  <tr>
    <th><%= t('entries.rank') %></th>
    <th><%= t('entries.name') %></th>
    <th><%= t('entries.score') %></th>
    <th><%= t('labels.delete') %></th>
  </tr>
  <% @entries.each do |entry|  %>
    <%= render_cell :entry, :show, entry: entry %>
  <% end %>
</table>
<span style='font-size: small; font-style:italic; float:right'><%= t('labels.edit_help') %></span>
<%= paginate @page_array %>

I am using the t (short for I18n.translate) method to get my strings out of the locale files. The entries will be displayed as a table (the sound of 100s of CSS pedants guffawing).

As previously mentioned, I am using the cells gem because I really like it. This view has two cells: the leaderboard new entry form and a cell to render an individual entry in the table. The new entry form cell looks like:

<%= form_tag controller: 'entries'  do %>
  <div class="row">
    <div class="large-8 columns">
      <div class="row collapse prefix-radius">
        <div class="small-3 columns">
          <span class="prefix"><%= t('entries.name') %></span>
        </div>
        <div class="small-9 columns">
          <%= text_field_tag :name, nil, {name: 'entry[name]'}  %>
        </div>
      </div>
    </div>
    <div class="large-4 columns">
      <div class="row collapse prefix-radius">
        <div class="small-3 columns">
          <span class="prefix"><%= t('entries.score') %></span>
        </div>
        <div class="small-7 columns">
          <%= number_field_tag :score, nil, {name: 'entry[score]'} %>
        </div>
        <div class="small-2 columns ">
          <%= submit_tag 'Add', class: 'button postfix' %>
        </div>
      </div>
    </div>
  </div>
<% end %>

This is just a simple form to add a new entry. It takes a name and a score.

The entry cell is a bit more interesting:

<tr>
  <td style='width: 10%'><%= @rank %></td>
  <td><%= @name %></td>
  <td style='width: 10%'>
    <entry-form entry='<%= @entry.to_json %>'></entry-form>
  </td>
  <td style="width: 10%"><%= render_cell :entry, :delete_form, entry: @entry %></td>
</tr>

The cell renders a single table row that contains the entry name, something called entry-form, and ANOTHER cell. We’ll come back to that weird entry-form component, let’s look at the delete_form cell, real quick:

<%= form_tag "/entries/#{@entry[:member]}", controller: 'entries',  method: 'DELETE' do %>
    <%= submit_tag "x", style: 'color: #fff; background: #F00; border:none;cursor: pointer' %>
<% end %>

The delete form uses an HTTP DELETE to post to the specific entry. This is one of the reasons I like cells. I get focused bits of the view that are testable.

So, what is that entry-form from the entry cell? That, my friends, is an Angular directive or component. Angular directives are reusable bits of javascript behavior. This directive allows the user to double-click on the score to edit it. Here it is, in all its glory:

var App = angular.module('App', [])
.directive('entryForm', function (){

  return {
    restrict: 'E',
    scope: {
      entry: '=entry'
    },
    templateUrl: "<%=asset_path 'templates/entry_form.html'%>",
    link: function(scope, elem, attrs) {
      var input = elem.find('input'),
          form = elem.find('form');
      scope.editing = false;

      elem.bind('dblclick', function(){
        scope.editing = true;
        input.select();
        scope.$apply();
      });

      input.bind('keydown', function(e) {
        switch(e.which) {
          case 13: //Enter
            form.submit();
          case 27: //Esc
            scope.editing = false;
        }
        scope.$apply();
      });
    }
  };
})

If you know anything about Angular directives (or even, if you don’t), this one is pretty simple. I am running the javascript file through ERb (meaning, it’s a .js.erb file) to get the asset_path, which is a questionable practice. The template holds the HTML that will replace the entry_form component on the page:

<span ng-hide='editing'>{{entry.score}}</span>
<form method='post' action='/entries' ng-show='editing' csrf>
  <input style='display:none' type='text' name='entry[name]' value='{{entry.member}}'/>
  <input style='width:100%' ng-show='editing' name='entry[score]' value='{{entry.score}}' />
</form>

The directive binds to a couple of events, one on the score element (dblclick) and one on the input (keyDown). The scope.editing boolean toggles the input on and off.

The more astute Rails developers among you may be asking: “How does that form get around CSRF protection? I don’t see the authenticity_token!” Good question. Did you see the csrf attribute on the form in the entry_form template? Guess what? It’s another directive! WHEEEE! Here’s what it looks like:

....entry-form directive...
.directive('csrf', function(){
  return {
    restrict: 'A',
    compile: function(tElement, tAttrs, transclude) {
      var input = $("<input/>", {type: 'hidden', name:'authenticity_token', value: $("meta[name='csrf-token']").attr("content") });
      tElement.append(input);
    }
  };
});

Putting csrf on that form tag causes a hidden input with the csrf-token to be appended to the form. Nifty.

That’s all for the single HTML page, style, and behavior. Oh, this is what it looks like:

lb

EntriesController

The EntriesController is where business gets done:

class EntriesController < ApplicationController
  def show
    entry = retrieve_service.execute(name: params[:id])
    return not_found unless entry
    respond_to do |format|
      format.html do
      end
      format.json do
        render json: entry, status: :ok
      end
    end
  end

  def create
    result = create_service.execute(entry_params)
    respond_to do |format|
      format.html do
        redirect_to root_path(page: result[:page])
      end
      format.json do
        render json: { status: :ok }
      end
    end
  end

  def index
    @entries = retrieve_service.execute(query_options)
    return not_found if @entries.blank?
    respond_to do |format|
      format.html do
      end
      format.json do
        render json: @entries
      end
    end
  end

  def destroy
    result = delete_service.execute(name: params[:id])
    return not_found unless result
    respond_to do |format|
      format.html do
        redirect_to root_path
      end
      format.json do
        render json: { status: 'ok' }, status: 200
      end
    end
  end

  private

  def create_service
    Boards::UpdateService.new
  end

  def retrieve_service
    Boards::GetService.new
  end

  def delete_service
    Boards::DeleteService.new
  end

  def entry_params
    (params[:entry] || {}).slice(:name, :score)
  end
end

I am not going to walk through each method here, as they all follow the same pattern: Call the right service object passing in the appropriate parameters, then render based on the format. There are couple of refactorings here that I’ll give you as homework. The destroy method redirects to the root path, which is gross. Your task is to make it all AJAXy and stuff.

But Wait, There’s More

The application presented here only touches part of what the leaderboard gem offers. The Github page has all the info, but I’d like to call out a couple of features.

Member Data

The gem allows you to provide additional member data to an entry. Here’s the example from the repo:

require "json"
lb.rank_member('Scout', 9001, {level: "over 9000"}.to_json)

At that point, calling:

JSON.parse(lb.member_data_for("Scout")) => {level: 'over 9000'}

This could be insanely useful if your leaderboard entries have extra data to tote around.

Around Me

It’s trivial to get the entries around an entry:

lb.around_me('Scout')
=> [...a bunch of entries...{member: "Vegeta", score: 8999, rank: 9000}, {member:"Scout", score: 9001, rank: 9001},...a bunch of entries...]

Other Types of Leaderboards

There are 3 kinds of leaderboards (who knew?): Default, TieRankingLeaderboard, and CompetitionRankingLeadeboard. The difference is how entries with the same score are handled.

  • Default ranks them lexicographically.
  • TieRankingLeaderboard: Equal scores are given an equal rank, and subsequent scores take the next value. Example: 1,1,2,3,4,4,5
  • CompetitionRankingLeadeboard: Equal scores are given an equal rank, and subsequent scores skip a value for each equal member. Example: 1,1,3,4,4,4,7

Conditionally Rank Entries

It’s possible to define a lambda that determines if a entry will be ranked or not. Here’s the example from the Github page:

highscore_check = lambda do |member, current_score, score, member_data, leaderboard_options|
  return true if current_score.nil?
  return true if score > current_score
  false
end

highscore_lb.rank_member_if(highscore_check, 'david', 1337)
highscore_lb.score_for('david')
 => 1337.0
highscore_lb.rank_member_if(highscore_check, 'david', 1336)
highscore_lb.score_for('david')
 => 1337.0
highscore_lb.rank_member_if(highscore_check, 'david', 1338)
highscore_lb.score_for('david')
 => 1338.0

Cool.

Script to Generate Entries

As promised, here is the script I used to generate some entries in the leaderboard:

100.times do |i|
  params = {name: Faker::Name.name, score: Random.rand(100)}
  Boards::UpdateService.new.execute(params)
end

Go Forth and Rank

You are now armed with the knowledge to create your own leaderboards. From this point forward, I presume you will be creating leaderboards for everything. Let me know when you get the leaderboard called “Articles abount Ruby and Leaderboards” so I can vote for this article.

More:

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in Ruby, once a week, for free.