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

Safe navigation operator '&.' vs '.try' in Rails

14
.
11
.
2023
Mateusz Woźniczka
Ruby on Rails
Backend
Ruby
Tutorial

What does the ||= operator actually mean in Ruby?

14
.
11
.
2023
Mateusz Woźniczka
Ruby on Rails
Backend
Ruby
Tutorial

How to design an entity - DDD in Ruby on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Entity - DDD in Ruby on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Should I use instance variables in Rails views?

14
.
11
.
2023
Mateusz Woźniczka
Ruby on Rails
Frontend
Backend
Tutorial

Data Quality in Ruby on Rails

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

We started using Event Storming. Here’s why!

14
.
11
.
2023
Mariusz Kozieł
Event Storming
Visuality

First Miłośnicy Ruby Warsaw Meetup

14
.
11
.
2023
Michał Łęcicki
Conferences
Visuality

Should I use Action Filters?

14
.
11
.
2023
Mateusz Woźniczka
Ruby on Rails
Backend
Tutorial

Value Object - DDD in Ruby on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Introduction to DDD in Ruby on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Safe data migrations in Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Backend
Tutorial

I love dev, and so do we!

14
.
11
.
2023
Michał Łęcicki
Software
Conferences

Updated guide to recruitment process at Visuality

14
.
11
.
2023
Michał Łęcicki
Visuality
HR

Visuality Academy for wannabe Junior Engineers

14
.
11
.
2023
Michał Piórkowski
HR
Visuality

How to approach funding as an MVP

14
.
11
.
2023
Michał Piórkowski
Business
Startups

Visuality 13th birthday

14
.
11
.
2023
Michał Piórkowski
HR
Visuality

How To Receive Emails With a Rails App in 2021

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

Project Quality in IT - How to Make Sure You Will Get What You Want?

02
.
10
.
2024
Wiktor De Witte
Ruby on Rails
Project Management
Business

5 Trends in HR Tech For 2021

14
.
11
.
2023
Maciej Zdunek
Business
Project Management