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

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

WebUSB - Bridge between USB devices and web browsers

14
.
11
.
2023
Burak Aybar
Ruby on Rails
Frontend
Backend
Tutorial

Visuality comes to town - this time it's Poznań

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

How to choose a software house.

14
.
11
.
2023
Michał Piórkowski
Ruby on Rails
Business
Visuality

CSS Modules in Rails

14
.
11
.
2023
Adam Król
Ruby on Rails
Tutorial
Backend
Frontend

JSON API versus the NIH syndrome

14
.
11
.
2023
Nadia Miętkiewicz
Backend
Frontend
Tutorial

From Idea to Concept

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Business
Startups

Styling React Components

14
.
11
.
2023
Umit Naimian
Ruby on Rails
Frontend
Tutorial

How good design can help your business grow

14
.
11
.
2023
Lukasz Jackiewicz
Design
Business
Marketing

TODO not. Do, or do not.

29
.
11
.
2023
Stanisław Zawadzki
Ruby on Rails
Software

CS Lessons #003: Density map in three ways

14
.
11
.
2023
Michał Młoźniak
Ruby
Backend
Tutorial
Software

Clean code for the win

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

Crowd-operated Christmas Lights

14
.
11
.
2023
Nadia Miętkiewicz
Ruby on Rails
Backend

How to startup and be mature about it

14
.
11
.
2023
Rafał Maliszewski
Ruby on Rails
Startups
Business

A journey of a thousand miles begins with workshops

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

CS Lessons #002: Data structures

14
.
11
.
2023
Michał Młoźniak
Ruby
Software

Summary of Phoenix workshop at Visuality

14
.
11
.
2023
Karol Słuszniak
Ruby on Rails
Visuality
Backend

CS Lessons #001: Working with binary files

14
.
11
.
2023
Michał Młoźniak
Ruby
Software

CS Lessons #000: Introduction and motivation

14
.
11
.
2023
Michał Młoźniak
Ruby
Software

Working with 40-minute intervals

14
.
11
.
2023
Sakir Temel
Software
HR

THE MATURE TECH STARTUP DILEMMA: WHAT'S NEXT

14
.
11
.
2023
Susanna Romantsova
Startups