DEV Community

loading...

Table Talk: Creating a Roll20 Clone in Ruby on Rails

merlumina
A software developer-in-training currently in the Part Time Software Engineering program at Flatiron School
・7 min read

Today I'm going to talk about Table Talk, my Ruby on Rails project for phase 3 of Flatiron School. Table Talk is a tabletop role-playing game campaign manager, in the same vein as Roll20 or DnDBeyond, where you can organize your Dungeons and Dragons (or any other TTRPG system) games.

If you're not familiar with tabletop role-playing games, they generally consist of a small group of people, with one person as the GM (game master) and the rest as players who each have a character they portray. The GM will narrate the story and the players will decide how their characters react to the story. The games are organized into campaigns, which represent the overall story that is being told, and sessions, which are the individual periods of time everyone gets together to play. With that in mind, I'll start by going over the models Table Talk uses.

My Models

I started by planning out my models and database schema. Since my Rails app was going to have more models and more complex relationships than my Sinatra app, I wanted to plan it out on paper ahead of time so I had a roadmap of where I wanted to go and how I would get there. This ended up being a really great decision (imagine that, they tell you to plan ahead and it is helpful!).

Table Talk database diagram

This is the diagram I came up with. It is not 100% accurate to what I finally came up with (as I'll explain below), but is pretty close.

Users

Users can have many campaigns, either as a GM, or through characters as a Player. They can also have many Characters and Notes.

Campaigns

Campaigns belong to a GM. They have many Characters, and many Players through characters. They also have many Sessions and Notes.

Sessions...er, Seshions?

Seshions belong to a Campaign and can have many Notes.
I ran into an issue with naming here, since Sessions are used in the application for maintaining state, logging in/out, etc. But I didn't see any way around using the term "session" for these scheduled play events in a campaign either, since that is just what they're called in the community. Sometimes people might use the word "game" as well, but that term is used more loosely and could refer to several different things, so "session" is just the best and most descriptive term to use. Because I didn't want any naming conflicts, similar to how you might have a Klass class, I used the spelling "Seshion" to refer to campaign-sessions. This is the name of the model and association, but to avoid any confusion I also tried to use this spelling in any other code (such as helper methods) that wasn't user-facing. To keep things easier to understand for users of the app, I made sure the URL in routes.rb was spelled "sessions" as they would expect:

resources :seshions, path: 'sessions'
Enter fullscreen mode Exit fullscreen mode

Characters

Characters belong to a Campaign and to a Player.

Notes

Notes belong to a User and either a Campaign or a Session.

Associations

As you can probably guess from my outline above, there are a few associations I needed that were a bit more complex than just has_many and belongs_to. This was by far the trickiest part of the application. I had to do a lot of research online and find out how to tweak solutions for my specific situation, so I will highlight some of my solutions below.

Users and Campaigns

In the world of TTRPGs, for any given campaign you'll either be the GM or a player, but you can play in many different games and GM some and be a player in others. Because I wanted my User model both create/own campaigns and also play in them, I knew I had to use different names and specify the class name for the association. This is what the associations look like in my Campaign class:

class Campaign < ApplicationRecord
    belongs_to :gm, class_name: 'User'
    has_many :characters, dependent: :destroy
    has_many :players, through: :characters, class_name: 'User'
Enter fullscreen mode Exit fullscreen mode

However, in the User class things get a little bit trickier. I want a User to have many campaigns, but in this case the same association would essentially exist twice - a User has_many Campaigns as a GM, and a User has_many Campaigns through Characters as a Player. Turns out ActiveRecord does not like that, so these associations need unique names on the User class as well.

class User < ApplicationRecord
  has_many :campaigns_as_gm, class_name: 'Campaign', foreign_key: 'gm_id', dependent: :destroy
  has_many :characters, foreign_key: 'player_id', dependent: :destroy
  has_many :campaigns_as_player, through: :characters, source: :campaign
Enter fullscreen mode Exit fullscreen mode

Since one association is a has_many and the other is a has_many through, they work a little differently. If you try to define a class_name and foreign_key the same way as the has_many, ActiveRecord will complain that (in my case):
ActiveRecord::HasManyThroughSourceAssociationNotFoundError (Could not find the source association(s) "campaigns_as_player" or :campaigns_as_player in model Character. Try 'has_many :campaigns_as_player, :through => :characters, :source => <name>'. Is it one of player or campaign?) There is no player_id on the campaign table, and ActiveRecord isn't sure what table it should be looking at with the foreign key we've specified. Instead, :source tells ActiveRecord which table campaigns_as_player is referring to, so it can find the campaign_id on the characters table. (It took me a lot of fiddling to get this to work, but then while researching some terminology for writing this post, I found that the Odin Project has an article that explains how to do this perfectly. Wish I had found that sooner!)

Since I also wanted to be able to call @user.sessions to see ALL the campaigns a User is associated with (mostly for use in the users#show page), I created the following instance method:

  def campaigns
    self.campaigns_as_gm + self.campaigns_as_player
  end
Enter fullscreen mode Exit fullscreen mode

Notes

I wanted a notes feature for the app which would serve multiple purposes. They could be for talking about scheduling, asking questions, or keeping track of items and story details. I thought it would be nice to have notes on both Campaigns and Sessions, where campaign notes would be more general and casual, and session notes would be for keeping track of specific things that happen each session. This is a perfect job for polymorphic associations!

class Note < ApplicationRecord
    belongs_to :user
    belongs_to :commentable, polymorphic: true

class Campaign < ApplicationRecord
    has_many :notes, as: :commentable, dependent: :destroy

class Seshion < ApplicationRecord
    has_many :notes, as: :commentable, dependent: :destroy
Enter fullscreen mode Exit fullscreen mode

I chose to use the term :commentable instead of :noteable mainly because that seems to be the conventional term for adding comments, but also "noteable" has a different meaning, so "commentable" seemed to make more sense to me. I'm happy to listen to arguments for :noteable though!

Miscellaneous

Below I'll talk about a few other noteworthy snippets from my application.

ActiveRecord Scope Method

The project requirements included having at least one class-level method that could also be displayed in a corresponding URL. I decided to allow users to browse all the Users on the site and also look up which users are GMs and which users are Players in games.

class User < ApplicationRecord
  scope :gms, -> { joins(:campaigns_as_gm).distinct }
  scope :players, -> { joins(:campaigns_as_player).distinct }

#routes.rb
  resources :users do
    collection do
      get 'gms'
      get 'players'
    end
Enter fullscreen mode Exit fullscreen mode

This lets you go to /users/gms and /users/players to see those filtered lists of users.

:campaigns and :seshions modules for Notes

I needed a unique solution for my notes#create action, since I wanted the new note form to be on the show page for the commentable it belonged to, not inside a nested route. A user should just be able to go to the show page for a campaign or session and post a comment directly on that page, not have to go to a special page with just a form on it. I also wanted to reuse the same form partial. The issue I ran into was in my #create action - I couldn't figure out how to pass in the correct information to specify if the commentable was a Campaign or a Seshion. I searched around for solutions using various keywords, and finally after searching "how to add comments with polymorphic associations" I found this great tutorial from GoRails. By creating a module for :campaigns and :sessions for each place I had specified the nested :notes resource in my routes, I could create a commentable-specific controller that knew ahead of time what the commentable type for the note was. The generic NotesController has all the behavior that all Notes need, but the Campaigns::NotesController and Seshions::NotesController can set the commentable based on either the :campaign_id or :seshion_id from the params, which is where I was originally running into trouble.

#routes.rb
  resources :campaigns, except: [:destroy] do
    resources :notes, module: :campaigns
    resources :seshions, path: 'sessions' do
      resources :notes, module: :seshions
    end
  end

class Seshions::NotesController < NotesController
    def set_commentable
        @commentable = Seshion.find_by(id: params[:seshion_id])
    end

class Campaigns::NotesController < NotesController
    def set_commentable
        @commentable = Campaign.find_by(id: params[:campaign_id])
    end
Enter fullscreen mode Exit fullscreen mode

Helper Methods

Here are some fun helper methods I used!

#delete_asset

Generic delete button for different asset types.

def delete_asset(link_text, path, css_class, confirm_text)
    link_to link_text, path, method: :delete, class: css_class, data: { confirm: confirm_text }
end
Enter fullscreen mode Exit fullscreen mode

#edit_or_join_campaign

Displays the proper button next to the Campaign name on the Campaign show page depending on if you are the GM, a current player, or not a player yet.

def edit_or_join_campaign
    if @campaign.gm == current_user
        link_to("Edit", edit_user_campaign_path(current_user, @campaign), class: "btn mr-2") + delete_asset("Delete Campaign", user_campaign_path(current_user, @campaign), "btn", "Are you sure?")
    elsif current_user && !current_user.campaigns.include?(@campaign)
        link_to "Join", new_campaign_character_path(@campaign), class: "btn"
    elsif current_user && current_user.campaigns.include?(@campaign)
        delete_asset("Leave Campaign", campaign_character_path(@campaign, character(current_user)), "btn", "Are you sure? Your character will be deleted!")
    end
end
Enter fullscreen mode Exit fullscreen mode

#display_note_form_if_campaign_includes_current_user

Only displays the new note form if the current user is a member of the campaign. You can't comment on a campaign you aren't a part of!

def display_note_form_if_campaign_includes_current_user(commentable, path)
    if current_user && current_user.campaigns.include?(@campaign)
        render partial: 'notes/form', locals: { commentable: commentable, path: path }
    end
end
Enter fullscreen mode Exit fullscreen mode

Conclusion

That's the basic writeup of the unique features of my app! I hope you enjoyed!

Github Repo: [https://github.com/merlumina/tabletalk/]

Discussion (0)

Forem Open with the Forem app