Leaderboards on Rails

Share this article

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.

Frequently Asked Questions (FAQs) about Leaderboards in Rails

How can I create a leaderboard in Rails?

Creating a leaderboard in Rails involves several steps. First, you need to create a model for your leaderboard. This model will store the scores of each user. You can use the rails generate model command to create this model. After creating the model, you need to create a controller for your leaderboard. The controller will handle the logic of updating the scores and displaying the leaderboard. You can use the rails generate controller command to create this controller. Finally, you need to create a view for your leaderboard. The view will display the leaderboard to the users. You can use the rails generate view command to create this view.

How can I update the scores in my Rails leaderboard?

Updating the scores in your Rails leaderboard can be done in the controller. You can create a method in your controller that takes the user and the new score as parameters. This method will find the user in the database, update their score, and save the changes to the database. Here is an example of how you can do this:

def update_score(user, new_score)
user = User.find_by(name: user)
user.score = new_score
user.save
end

How can I display the leaderboard in Rails?

Displaying the leaderboard in Rails can be done in the view. You can create a table in your view that displays the name and score of each user. You can use the each method to iterate over each user and display their name and score. Here is an example of how you can do this:

<table>
<% @users.each do |user| %>
<tr>
<td><%= user.name %></td>
<td><%= user.score %></td>
</tr>
<% end %>
</table>

How can I sort the leaderboard in Rails?

Sorting the leaderboard in Rails can be done in the controller. You can use the order method to sort the users by their scores. You can then pass the sorted users to the view. Here is an example of how you can do this:

def index
@users = User.order(score: :desc)
end

How can I add pagination to my Rails leaderboard?

Adding pagination to your Rails leaderboard can be done using the will_paginate gem. First, you need to add the gem to your Gemfile and run bundle install. Then, you can use the paginate method in your controller to paginate the users. Finally, you can use the will_paginate method in your view to display the pagination links. Here is an example of how you can do this:

# In your controller
def index
@users = User.order(score: :desc).paginate(page: params[:page], per_page: 10)
end

# In your view
<%= will_paginate @users %>

How can I add a search function to my Rails leaderboard?

Adding a search function to your Rails leaderboard can be done using the ransack gem. First, you need to add the gem to your Gemfile and run bundle install. Then, you can use the ransack method in your controller to create a search object. Finally, you can use the search_form_for method in your view to create a search form. Here is an example of how you can do this:

# In your controller
def index
@q = User.ransack(params[:q])
@users = @q.result(distinct: true).order(score: :desc)
end

# In your view
<%= search_form_for @q do |f| %>
<%= f.label :name_cont, "Search by name" %>
<%= f.search_field :name_cont %>
<%= f.submit %>
<% end %>

How can I add a filter function to my Rails leaderboard?

Adding a filter function to your Rails leaderboard can be done using the ransack gem. First, you need to add the gem to your Gemfile and run bundle install. Then, you can use the ransack method in your controller to create a search object. Finally, you can use the search_form_for method in your view to create a filter form. Here is an example of how you can do this:

# In your controller
def index
@q = User.ransack(params[:q])
@users = @q.result(distinct: true).order(score: :desc)
end

# In your view
<%= search_form_for @q do |f| %>
<%= f.label :score_gteq, "Filter by score" %>
<%= f.number_field :score_gteq %>
<%= f.submit %>
<% end %>

How can I add a reset function to my Rails leaderboard?

Adding a reset function to your Rails leaderboard can be done in the controller. You can create a method in your controller that resets the scores of all users. This method will find all users in the database, set their scores to zero, and save the changes to the database. Here is an example of how you can do this:

def reset_scores
User.all.each do |user|
user.score = 0
user.save
end
end

How can I add a delete function to my Rails leaderboard?

Adding a delete function to your Rails leaderboard can be done in the controller. You can create a method in your controller that deletes a user from the leaderboard. This method will find the user in the database and delete them. Here is an example of how you can do this:

def delete_user(user)
user = User.find_by(name: user)
user.destroy
end

How can I add a registration function to my Rails leaderboard?

Adding a registration function to your Rails leaderboard can be done using the devise gem. First, you need to add the gem to your Gemfile and run bundle install. Then, you can use the devise command to generate the necessary models, views, and controllers. Finally, you can use the devise_for method in your routes file to add the registration routes. Here is an example of how you can do this:

# In your Gemfile
gem 'devise'

# In your terminal
bundle install
rails generate devise User

# In your routes file
devise_for :users

Glenn GoodrichGlenn Goodrich
View Author

Glenn works for Skookum Digital Works by day and manages the SitePoint Ruby channel at night. He likes to pretend he has a secret identity, but can't come up with a good superhero name. He's settling for "Roob", for now.

GlennGrailsruby
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week