I was recently on a project that needed a “join code/game pin” feature similar to those found in multiplayer quiz games.

I naively thought this could be achieved in a matter of hours, but soon realized there was a lot of nuance. This is the article I wish existed when I started working on this feature.
Understanding the requirements
A join code needs to be short, easy to type, as well as easy to read or even say
aloud (in cases where the user cannot see the code). Because of this, APIs like
generate_token_for weren’t going to work, because
they’re not meant to be typed, or viewed. They’re meant to be clicked.
Because of this, most join codes are 6 characters long, consisting of numbers, letters, or a combination of the two.
Naive implementation
Knowing this, I figured I would just generate a random 6 character digit and call it a day.
join_code = SecureRandom.random_number(1000000).to_s.rjust(6, "0")
# => "891907"
# Create game
Game.create!(join_code:)
# Find game
Game.find_by(join_code: params[:join_code])
This technique will yield 1,000,000 (10 digits with 6 possible positions = 10^6) possible combinations, which seems like it would be plenty.
But then I got to thinking:
- What happens if the feature is a massive success, and we eventually exhaust all of the possible 1,000,000 combinations?
- How likely are collisions if we can’t recycle join codes?
Understanding collision probability
In order to answer these questions, I found it easier to break the problem down into something simpler.
Let’s assume join codes are 1 character in length, and can only be a digit (0-9).
This means there’s always a 10% chance of randomly generating any number. But, once a value has been used, the probability of colliding increases linearly.
| Join Codes in DB (before) | Chance Generated Join Code Exists |
|---|---|
| 0 | 0.0% |
| 1 | 10.0% |
| 2 | 20.0% |
| 3 | 30.0% |
| 4 | 40.0% |
| 5 | 50.0% |
| 6 | 60.0% |
| 7 | 70.0% |
| 8 | 80.0% |
| 9 | 90.0% |
| 10 | 100.0% |
This means that once we have 500,000 rows in the database, there will be a 50% chance of a collision when generating the next join code. As each new row is added, the collision rate will increase.
This is a problem if we can’t recycle join codes, because we’ll need to handle these collisions on each attempt, which could result in an infinite loop.
Alphanumeric implementation
After realizing that 1,000,000 combinations may not be enough, I figured I’d go with an alphanumeric approach, since that would result in more combinations, and more time until a 50% collision rate.
join_code = SecureRandom.alphanumeric(6).upcase
# => "TOAJR7"
# Create game
Game.create!(join_code:)
# Find game
Game.find_by(join_code: params[:join_code])
Now there are 2,176,782,336 (10 digits + 26 letters with 6 possible positions = 36^6) possible combinations.
UX considerations with an alphanumeric join code
I thought the alphanumeric approach would be easy, until I realized there are some UX “gotchas”.
The first “gotcha” is that some numbers look like capitalized letters.
- 0 ↔ O
- 1 ↔ I
- 2 ↔ Z
- 5 ↔ S
- 8 ↔ B
- U ↔ V
The second “gotcha” is there’s a chance the randomly generated join code will contain a profanity or two.
Tools like Sqids Ruby account for both of these concerns.
The third “gotcha” is normalization. It’s a better UX not to have to capitalize individual characters when entering a join code. But this means you’ll want to normalize the value when making the query.
class Game < ActiveRecord::Base
normalizes :join_code, with: -> join_code { join_code.strip.upcase }
end
join_code = Game.normalize_value_for(join_code: params[:join_code])
Game.find_by(join_code:)
You’ll also want to make the value appears capitalized.
<style>
#join_code {
text-transform: uppercase;
}
</style>
<input type="text" id="join_code" name="join_code" />
Scoping to time, not space
We can avoid the collision and UX concerns by limiting when a join code can be used.
Instead of indexing on the join code alone, we can scope it to another column. In most cases, using a join code should be limited to a point of time, since games are temporary.
The simplest way to do this would be with an active boolean column. Here’s
what that schema might look like:
class CreateGames < ActiveRecord::Migration[8.0]
def change
create_table :games do |t|
t.string :join_code
t.boolean :active, default: false, null: false
t.timestamps
end
add_index :games, :join_code, unique: true, where: "active = true AND join_code IS NOT NULL"
add_check_constraint :games, "join_code IS NOT NULL OR active = false",
name: "active_games_require_join_code"
end
end
This would allow join codes to be recycled, while ensuring they’re unique across all “active” games.
# Create game
Game.create!
# Find an active game
Game.active.find_by(join_code: params[:join_code])
This means the probability of a collision would eclipse 50% when there are 500,000 active games, which is far less of a concern. We can actually use our naive number-based implementation from the beginning now that we understand how to scope the join code.
Here’s a complete example:
class Game < ApplicationRecord
JOIN_CODE_REGEX = /\A\d{6}\z/
validates :join_code, presence: true, if: :active?
validates :join_code, uniqueness: {
scope: :active,
conditions: -> { where(active: true) }
}
validates :join_code, format: {
with: JOIN_CODE_REGEX
}, if: :active?
before_validation :set_join_code, unless: :join_code?
scope :active, -> { where(active: true) }
private
def set_join_code
max_attempts = ENV.fetch("JOIN_CODE_GENERATION_MAX_ATTEMPTS")
max_attempts.times do
self.join_code = generate_join_code
next if @existing_game = Game.active.exists?(join_code:)
end
raise "Failed to generate unique join_code after #{max_attempts} attempts" if @existing_game
end
def generate_join_code
SecureRandom.random_number(1000000).to_s.rjust(6, "0")
end
end
Wrapping up
Let’s circle back to the original requirements:
A join code needs to be short, easy to type, as well as easy to read or even say aloud (in cases where the user cannot see the code).
By sticking with digits only, our complete example accounts for all of these requirements. It also provides a better UX because users won’t need to toggle between their number keys and character keys, or concern themselves with capitalization. It also alleviates us from needing to account for profanities or characters that look alike, as well as normalization.
It turns out, our naive implementation from the beginning was mostly right, but we needed to take the scenic route to come to that conclusion.