14
.
11
.
2023
24
.
10
.
2023
Ruby on Rails
Backend
Frontend
Hotwire

Tetris on Rails

Paweł Strzałkowski
Chief Technology Officer

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?

Tetris Game Screenshot

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 iteration
  • running is just a boolean marking whether the game is still in progress
  • tick 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!

Paweł Strzałkowski
Chief Technology Officer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

How to become a Ruby Certified Programmer Title image

How to become a Ruby Certified Programmer

14
.
11
.
2023
Michał Łęcicki
Ruby
Visuality
Vector Search in Ruby - Paweł Strzałkowski

Vector Search in Ruby

17
.
03
.
2024
Paweł Strzałkowski
ChatGPT
Embeddings
Postgresql
Ruby
Ruby on Rails
LLM Embeddings in Ruby - Paweł Strzałkowski

LLM Embeddings in Ruby

17
.
03
.
2024
Paweł Strzałkowski
Ruby
LLM
Embeddings
ChatGPT
Ollama
Handling Errors in Concurrent Ruby, Michał Łęcicki

Handling Errors in Concurrent Ruby

14
.
11
.
2023
Michał Łęcicki
Ruby
Ruby on Rails
Tutorial
Recap of Friendly.rb 2024 conference

Insights and Inspiration from Friendly.rb: A Ruby Conference Recap

02
.
10
.
2024
Kaja Witek
Conferences
Ruby on Rails

Covering indexes - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Ruby on Rails
Postgresql
Backend
Ula Sołogub - SQL Injection in Ruby on Rails

The Deadly Sins in RoR security - SQL Injection

14
.
11
.
2023
Urszula Sołogub
Backend
Ruby on Rails
Software
Michal - Highlights from Ruby Unconf 2024

Highlights from Ruby Unconf 2024

14
.
11
.
2023
Michał Łęcicki
Conferences
Visuality
Cezary Kłos - Optimizing Cloud Infrastructure by $40 000 Annually

Optimizing Cloud Infrastructure by $40 000 Annually

14
.
11
.
2023
Cezary Kłos
Backend
Ruby on Rails

Smooth Concurrent Updates with Hotwire Stimulus

14
.
11
.
2023
Michał Łęcicki
Hotwire
Ruby on Rails
Software
Tutorial

Freelancers vs Software house

02
.
10
.
2024
Michał Krochecki
Visuality
Business

Table partitioning in Rails, part 2 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
Ruby on Rails

N+1 in Ruby on Rails

14
.
11
.
2023
Katarzyna Melon-Markowska
Ruby on Rails
Ruby
Backend

Turbo Streams and current user

29
.
11
.
2023
Mateusz Bilski
Hotwire
Ruby on Rails
Backend
Frontend

Showing progress of background jobs with Turbo

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails
Ruby
Hotwire
Frontend
Backend

Table partitioning in Rails, part 1 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend
Ruby on Rails

Table partitioning types - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend

Indexing partitioned table - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
SQL Views in Ruby on Rails

SQL views in Ruby on Rails

14
.
11
.
2023
Jan Grela
Backend
Ruby
Ruby on Rails
Postgresql
Design your bathroom in React

Design your bathroom in React

14
.
11
.
2023
Bartosz Bazański
Frontend
React
Lazy Attributes in Ruby - Krzysztof Wawer

Lazy attributes in Ruby

14
.
11
.
2023
Krzysztof Wawer
Ruby
Software

Exporting CSV files using COPY - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Ruby
Ruby on Rails
Michał Łęcicki - From Celluloid to Concurrent Ruby

From Celluloid to Concurrent Ruby: Practical Examples Of Multithreading Calls

14
.
11
.
2023
Michał Łęcicki
Backend
Ruby
Ruby on Rails
Software