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

JSON:API consumption in Rails

14
.
11
.
2023
Jan Matusz
Ruby on Rails
Backend
Tutorial

Marketing hacks #01: How to Track off-line conversions

14
.
11
.
2023
Marek Łukaszuk
Ruby on Rails
Business
Marketing

Common communication issues in project management

02
.
10
.
2024
Michał Krochecki
Project Management

Selected SXSW lectures takeaways

14
.
11
.
2023
Michał Piórkowski
Conferences
Frontend
Backend
Business

SXSW Summary

14
.
11
.
2023
Michał Piórkowski
Ruby on Rails
Conferences
Frontend
Backend
Business

How to get the most out of SXSW Interactive

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Conferences
Frontend
Backend
Business

Guide to recruitment at Visuality

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

TOP Ruby on Rails Developers

14
.
11
.
2023
Maciej Zdunek
Ruby on Rails
Visuality
Business

How to conquer Westworld?

14
.
11
.
2023
Maciej Zdunek
Business
Marketing

2018 Rewind by Visuality

02
.
10
.
2024
Michał Krochecki
HR
Visuality

Quality Assurance Testing

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

Why do we like to be together?

02
.
10
.
2024
Michał Krochecki
Visuality
HR

Wallboards - a great value for our teams and clients

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Design
Project Management
Backend

2018 Clutch Global Leader

14
.
11
.
2023
Maciej Zdunek
Ruby on Rails
Visuality
Business
Marketing

Hot topic: Progressive Web Apps instead of native mobile apps

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Business
Backend
Frontend

Docker hosted on Jelastic

14
.
11
.
2023
Marcin Prokop
Ruby on Rails
Backend
Tutorial

All the pieces matter - Visuality DNA

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

Tech conferences 2018/2019 you definitely should attend

02
.
10
.
2024
Michał Krochecki
Conferences

Visuality Poznań is here!

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

Why we chose Ruby on Rails and React.js for our main technologies? (FAQ).

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Backend
Frontend
Visuality

Branding: How to style your Jira?

14
.
11
.
2023
Lukasz Jackiewicz
Tutorial
Design
Project Management

How to start your UX/UI designer career

14
.
11
.
2023
Bartłomiej Bednarski
Design
Tutorial
HR