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:
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,5CompetitionRankingLeadeboard
: 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 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.