The story behind Tetris on Rails
It has all started at Ruby Warsaw Community Conference where I had a pleasure of giving a talk titled Rails Permanent Job - how to build a Ruby on Rails server using ServerEngine When it came to the afterparty, Sergy Sergyenko, the organizer of the Euruko 2023 conference, challenged me to create a game for a ticket raffle. Participants would play the game, and the winners would receive free tickets to Euruko 2023. I was thrilled to be a part of this challenge.
After a short but intense consideration, I've decided to implement Tetris.
Why Tetris?
This game has several benefits:
- it's well known, we don't have to explain the rules
- it's easy to score
- it's short
- it's fun
The idea was to:
- create an in-browser game
- allow any number of games played for any person, but only one at a given moment
- process all the logic in backend Ruby on Rails app
- remember all the scores and register players' emails to be able to contact the winners
The backend
One of the requirements was to put the entire game logic in the backend, inside of a Ruby on Rails application. It means that there should be a persistable model, expressed (for example) in ActiveRecord and an appropriate database schema.
Game model
A Tetris game is made of a brick, a board and score. With that idea, I've created a games
table.
create_table "games", force: :cascade do |t|
t.uuid "player_id"
# Brick
t.string "brick_shape"
t.integer "brick_position_x"
t.integer "brick_position_y"
t.integer "brick_rotated_times"
t.string "next_brick_shape"
# Board
t.text "board", array: true
# Score
t.integer "score", default: 0
t.text "actions", array: true
t.boolean "running", default: true
t.integer "tick", default: 0
# Timestamps
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
There are also a few other columns:
actions
stores a list of actions to be performed in the next game loop iterationrunning
is just a boolean marking whether the game is still in progresstick
holds the time elapsed since the beginning of the game
With this setup, the game was quite straightforward to make. It was built using two parts:
Ruby on Rails application
The routing is based on a single controller - GamesController
Rails.application.routes.draw do
resources :games, only: [:index, :create] do
collection do
get :play
get :move_left
get :move_right
get :rotate
end
end
end
The /games/play
route is there to show a single game per player using a meaningful endpoint. The GameController
allows to create a new game as well as to register a left/right move or rotation.
# Example endpoints
class GamesController < ApplicationController
def create
game = Game.build_for_player(@player_id, ...)
game.save!
redirect_to play_games_path(game)
end
def move_left
game = Game.find_by(player_id: @player_id)
if game
game.register_action(Action::MOVE_LEFT)
game.save
end
render :no_content
end
# ...
end
The move_left
action shows that moves are not directly applied to the game but rather registered for future processing. It will be explained further in the article.
With these actions, we can move the brick, but the game doesn't progress. What we need, is an ongoing process, which applies gravity or deals with the brick touching ground.
Game Loop
The continuous process responsible for the game progress could be implemented in a number of ways. The easiest one would be to create a simple rake task with an infinite loop inside. However, as described in
the presentation linked at the beginning, a more robust way would be to use Permanent Job Gem
.
With this library, the task looks simple:
namespace :game do
desc 'Run Game'
task start: :environment do
RailsPermanentJob.jobs = [GameRound]
RailsPermanentJob.after_job = ->(**_) { sleep GameRound::SLEEP_TIME }
RailsPermanentJob.run
end
end
It continuously runs GameRound.call
with a bit of sleep between rounds. With the power of RailsPermanentJob gem, the task is robust and would be smoothly restarted if it ever breaks. But, what is a GameRound
?
class GameRound
TICKS_PER_GAME_TICK = 3
def self.call(logger:, **)
games = Game.where(running: true)
games.each do |game|
game.perform_registered_actions if game.actions.any?
if game.tick % TICKS_PER_GAME_TICK == 0
if game.brick
game.apply_gravity
else
game.handle_full_lines
end
end
game.tick += 1
game.save!
end
end
Within every GameRound
, all the running games are progressed by a singe tick of time. In that moment, all the registered moves are executed. This synchronises user actions with the game logic processing. Moreover, every 3 ticks, gravity is applied and repercussions of brick's position are calculated.
All we've covered so far has been happening in the deep backend. It's time to bring the game to the players.
Frontend
With every tick of a game, its state changes. The updated state has to be rendered in the player's browser. There are two potential approaches to do it:
- pulling the state from the server to a browser with JavaScript-driven calls
- pushing the state from the server to a browser over a socket connection
Pulling uses a lot of server resources and is inefficient at a high-paced game. It either loads the state too frequently or not frequently enough to provide a smooth game experience. On the other hand, pushing updates the in-browser state only when it's necessary.
Thankfully, pushing HTML over the socket connection is well supported by Ruby on Rails in the form of Hotwire.
Hotwire
The goal of using Hotwire in this application is to make sure that every change done by the game loop to the Game object is transported to player's browser.
Even though it is a cutting edge technology, it quite trivial to use with Ruby on Rails. There are only three steps needed:
1. Tell your model it is broadcasted
# app/models/game.rb
class Game < ApplicationRecord
broadcasts_to ->(game) { "game_#{game.id}" }
# ...
end
2. Prepare a Turbo Stream for it
# views/games/play.html.erb
<div class="game-wrapper" data-controller="game">
<%= turbo_stream_from dom_id(@game) %>
<%= render @game %>
</div>
3. Create the view
# views/games/_game.html.erb
<div class="game" id="<%= dom_id(game) %>">
<!-- just HTML things -->
</div>
It's all you need. with this preparation, every update to the Game model will be automatically reflected in player's view. You may use it in your projects to keep the view data up to date for your users.
Play it yourself
The game isn't hosted online any more, but you can easily run it locally. The source code is available at https://github.com/pstrzalk/tetris-on-rails
It is the full version used for the Euruko 2023 ticket raffle. On top of the described functionality, it has a few other features, including:
- questions asked whenever more than two rows are cleared at once
- ability to register your email and nickname when the game is finished
- tweaks for decreasing the number of Hotwire data transfers
- dynamic flag guarding the volume of active players
Feel free to find them and let me know if you have any questions. Just follow the instructions in the readme and... enjoy!