Build a Memory Game with Rails, Stimulus JS, and Tailwind CSS

Semaphore
10 min readApr 25, 2023

--

Stimulus is a JavaScript framework that’s designed to augment your HTML with modest ambitions and just enough behavior to make it shine. It doesn’t seek to take over your entire front-end, nor is it concerned with rendering HTML.

In this exercise, we’ll take a closer look at the key features of Stimulus JS and examine the appropriate use cases for each one. We’ll explore how Stimulus JS can be used to add interactivity and functionality to server-side rendered applications, and we’ll consider the benefits of its lightweight architecture and modular approach to building user interfaces.

Prerequisites

You will need the following. I am using a MacOS.

  • Docker installed
  • Knowledge of Ruby on Rails
  • Knowledge of Javascript

Finished Game

The codebase of the completed game can be found HERE.

NOTE: If you’re encountering any obstacles in getting things to function correctly, please refer to the repository where the completed game codebase is available.

Setting up a new rails app

Create a folder on your machine and navigate into it. I will use the name matching-game-demo

mkdir matching-game-demo && cd matching-game-demo

Generate Dockerfile with the following content. This file is used to build our docker image.

FROM ruby:3.1.0
  WORKDIR /matching-game  RUN apt-get update -yqq \
&& apt-get install -yqq --no-install-recommends \
build-essential \
curl \
gnupg2 \
libpq-dev \
nodejs \
npm \
&& npm install -g npm \
&& npm install -g yarn \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - \
&& apt-get update -yqq \
&& apt-get install -yqq --no-install-recommends nodejs \
&& npm install -g esbuild
EXPOSE 3000

Generate docker-compose.yml file with the following content. Docker compose helps us spin up everything or tear it down using a single command.

services:
web:
build:
context: .
dockerfile: Dockerfile
tty: true
volumes:
- .:/matching-game
ports:
- "3000:3000"
command: bin/dev

In your terminal, run the command to access bash inside our Docker container.

docker compose run web bash

Inside bash, we can confirm that npm, yarn, bundler and node are present by checking their versions.

Install the rails version you want to use.

gem install rails -v 7.0

Generate a new rails app. The dot (.) means to create a new app in the current folder and use that as the name. In our case, ‘matching-game-demo’

rails new . --css=tailwind --javascript=esbuild

Exit the bash session.

Edit the Dockerfile and append commands for copying the generated files to the container and running system setup, i.e. dependencies, installations, and database migrations.

COPY . /matching-game/
  RUN yarn install --check-files  RUN bin/setup

In terminal, run the following command:

docker compose build

To start the web server, run the command and visit http://0.0.0.0:3000 in your browser.

docker compose up

Game board user interface

In another terminal, navigate to the matching-game-demo/ directory and generate the dashboard controller with the index method.

docker compose run web rails generate controller dashboard index

Set the dashboard index method as the root path.

# ./config/routes.rb
root "dashboard#index"

The game board is a two-dimensional square. In this article, we will only focus on the dimensions of 2, 4 and 6.

Create a variable called @board_size.

# ./app/controllers/dashboard controller
def index
# Note: This can be either 2,4 or 6
@board_size = 4
end

Next, we will create the tiles. The total number of tiles for the board is board_size ^ 2.

A tile will be a div element which has the index value as its content.

<!-- ./app/views/dashboard/index.html.erb -->
<div id="board" class="">
<% (@board_size * @board_size).times do |index| %>
<div id="tile_<%= index %>" class="">
<%= index %>
</div>
<% end %>
</div>

Right now, the structure of the board is wrong; we want a square shape. We can solve this by using tailwind grid.

<!-- ./app/views/dashboard/index.html.erb -->
<%
boardClassName = "
bg-gradient-to-r from-violet-500 to-indigo-500 p-8
rounded aspect-square justify-center items-center
grid gap-6 grid-cols-#{@board_size} grid-rows-#{@board_size}
"
tileClassName = "
shadow-sm hover:shadow-2xl rounded bg-white
justify-self-center w-24 max-w-36 aspect-square
"
%>
<div id="board" class="<%= boardClassName %>">
...
<div id="tile_<%= index %>" class="<%= tileClassName %>">
...
</div>
</div>

Before we test in the browser, let’s keep a few things in mind:

  • We are setting the background color of each tile to White using bg-white n tileClassName
  • The board’s grid, rows, and columns count are set dynamically using grid-cols-[VALUE] and grid-rows-[VALUE] where the VALUE is @board_size.

Upon testing, you may have noticed that when you change the @board_size value on the dashboard controller, the board structure does not persist.

This is because Tailwind does not work well with dynamically-set classes. To avoid this issue, we can preload the expected classes before we use them.

This is done using tailwind safelist.

Source: tailwindcss-rails repo on Github.

# tailwind.config.js
module.exports = {
content: [...],
safelist: [
'grid-cols-2', 'grid-rows-2',
'grid-cols-4', 'grid-rows-4',
'grid-cols-6', 'grid-rows-6',
],
}

Last, let’s add styling to the HTML body element.

<!-- ./app/views/layouts/application.html.erb -->
...
<body class="
flex flex-col items-center justify-center
bg-fixed h-screen w-full"
>
<%= yield %>
</body>

Stimulus game board controller

Create a stimulus controller and attach it to the board div element.

docker compose run web rails generate stimulus board
<!-- ./app/views/dashboard/index.html.erb -->
...
<div
id="board"
class="<%= boardClassName %>"
data-controller="board"
>
...

We can add a console.log to the connect method of the board controller for testing.

/* ./app/javascript/controllers/board_controller.js */
...
connect() {
/* For testing. Remove when done. */
console.log("Board controller connected")
}

When we reload our UI and inspect the console, You can see the test “Board controller connected”. The connect method is called when the page with the HTML element data-controller is loaded.

Before we proceed, go ahead and change the following:

  • Replace bg-white with bg-black in the tileClassName.
  • Remove the index text content inside each tile div element.

Next, we want to add a click action to each tile, which changes the background color of the clicked tile to red and sets the text content to its index value for 1 second and then reverts these changes.

Refer to Stimulus Actions to learn more.

<!-- ./app/views/dashboard/index.html.erb -->
...
<div
id="tile_<%= index %>"
class="<%= tileClassName %>"
data-action="click->board#flip"
data-tile-index="<%= index %>"
>
/* ./app/javascript/controllers/board_controller.js */
...
flip(event) {
const tile = event.target
const tileIndex = Number(tile.dataset.tileIndex)
this.#showContent(tile, tileIndex) setTimeout(() => {
this.#hideContent(tile)
}, 1000)
}
#showContent(tile, tileIndex) {
tile.textContent = tileIndex
tile.style.backgroundColor = "red"
tile.classList.remove("bg-black")
}
#hideContent(tile) {
tile.textContent = null
tile.style.backgroundColor = null
tile.classList.add("bg-black")
}

When a tile is clicked, the flip method is called and we extract both the tile – the div element – and tileIndex. Then we call the private method showContent, which sets the text content to the tileIndex value, the background color to red, and also it removes the tailwind class bg-black.

One second later, the hideContent reverts the showContent changes.

NOTE: We access data-attributes on the stimulus controller using camelCase.

For example:

const tileIndex = Number(tile.dataset.tileIndex)

Game board store outlet

Our game needs to have the following features:

  • When an opened tile is clicked again, nothing happens.
  • Compare if two successively clicked tiles have the same background color and text content.
  • Do nothing if the two successively clicked tiles have the same background color and text content
  • Reset the two successively clicked tiles if their text content and background color are not the same.

This is a lot of functionality to have inside the board controller file.

Stimulus provides a solution to this called Outlets that allows us to reference other stimulus controllers from within our board controller.

To solve the double clicking of the same tile, let’s add data-tile-is-open=false to our tile div element and if that value is true, we do nothing.

<!-- ./app/views/dashboard/index.html.erb -->
...
<div
id="tile_<%= index %>"
data-action="click->board#flip"
data-tile-index="<%= index %>"
data-tile-is-open="false"
>
...
/* ./app/javascript/controllers/board_controller.js */
...
flip(event) {
const tile = event.target
const tileIndex = Number(tile.dataset.tileIndex)
const tileIsOpen = String(tile.dataset.tileIsOpen)
if (tileIsOpen === "false") {
...
}
}
#showContent(tile, tileIndex) {
...
tile.dataset.tileIsOpen = "true"
}
#hideContent(tile) {
...
tile.dataset.tileIsOpen = "false"
}

Now we can create the store stimulus controller that will act as an outlet.

docker compose run web rails generate stimulus store
<!-- ./app/views/dashboard/index.html.erb -->
...
<div id="store--elements" data-controller="store"></div>
<div
id="board"
class="<%= boardClassName %>"
data-controller="board"
data-board-store-outlet="div#store--elements"
>
...
</div>

Note: You can either use ID or CLASS to pass the element whose controller you want to be the outlet. I am using ID.

/* ./app/javascript/controllers/board_controller.js */
export default class Controller {  /* Name should match the controller name */
static outlets = [ "store" ]
...
flip() {
/* For testing only. Remove when done */
this.storeOutlet.test()
...
}
...
}
/* ./app/javascript/controllers/store_controller.js */
export default class Controller {
...
/* For testing only. Remove when done */
test() {
console.log("A tile has been clicked")
}
}

When we click a tile, we can see the text “A tile has been clicked” in the browser.

Next, we use the store controller to store an array of successively clicked tiles. These are the features we want:

  • Clicking a single tile more than once only pushes that tile to our array once.
  • Reset tiles when the array size is exactly two.
  • Only reset the tiles in the array.
/* ./app/javascript/controllers/store_controller.js */
export default class Controller {  connect() {
this.successiveTilesCollection = []
}
addToSuccessiveTilesCollection(tile){
this.successiveTilesCollection.push(tile)
}
resetSuccessiveTilesCollection() {
this.successiveTilesCollection = []
}
get successiveTilesCollectionCount() {
return this.successiveTilesCollection.length
}
}
/* ./app/javascript/controllers/board_controller.js */
export default class Controller {  ...  this.#showContent(tile, tileIndex)
this.storeOutlet.addToSuccessiveTilesCollection(tile)
if (this.storeOutlet.successiveTilesCollectionCount === 2) {
setTimeout(() => {
this.storeOutlet.successiveTilesCollection.forEach(tile => {
this.#hideContent(tile)
})
this.storeOutlet.resetSuccessiveTilesCollection()
}, 1000)
}
}

You will notice that now you have to click 2 different tiles before both of them are reset.

Game board tiles content

For a game board that has correct matching tiles, we need (@board_size ^ 2 / 2) unique items, i.e. tiles, that have the same background color and text content.

For example, for a 4 by 4 board, we need 18 unique tiles.

Let’s create two collections for color and label.

# ./app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController  ANIMALS = ['dog', 'cat', 'pig', 'owl', 'ant'] # Add more
COLORS = ['red', 'green', 'yellow', 'lime', 'pink'] # Add more
def index
@board_size = 4
@board_finished_result = generate_board_result(@board_size) # will build this next
end
end

Before we can build the generate_board_result feature, this is what we want:

  • We want to express a tile’s contents as [color, label], meaning that our board will be an array of arrays.

We will employ array#product from ruby’s documentation.

Examples of array#product:

# ruby irb
> [1,2].product([3,4])
(result) [ [1,3], [1,4], [2,3], [2,4] ]
> ['cat', 'dog'].product(['pink'])
(result) [ ['cat', 'pink'], ['dog', 'pink'] ]

Now, time to build the generate_board_result.

# ./app/controllers/dashboard_controller.rb
...privatedef generate_board_result(size)
board = create_board(size)
end
def create_board(size)
return [] unless size.even?
color_options = COLORS.shuffle.take(size / 2)
letter_options = ANIMALS.shuffle.take(size)
options = color_options.product(letter_options)
(options * 2).shuffle
end

As you can see, have the board tile values generated and randomized in the array. Next, we hashify the board so that the hash key joins color and label, and the hash value will be an array of the indexes of the tiles that have those contents.

Example of the expected hash result is:

{ 'red--dog': [0,2], 'green--monkey': [1,3] }
(explanation)
For a 2x2 board, tiles whose label is "dog" and background color is "red" are at indexes positions 0 and 2
# ./app/controllers/dashboard_controller.rb
...def generate_board_result(size)
board = create_board(size)
hashify_board(board).to_json
end
...def hashify_board(board)
result = {}
board
.group_by.with_index { |_, index| index }
.transform_values { |value| value.join("--")}
.each do |key, value|
result[value] ||= []
result[value] << key
result[value].uniq!
end
result
end

We can pass the @board_finished_result to our board controller via Stimulus Values, which allow us to read and write HTML data attributes in controller elements as typed values.

<!-- ./app/views/dashboard/index.html.erb -->
<div
id="board"
class="<%= boardClassName %>"
data-controller="board"
data-board-store-outlet="div#store--elements"
data-board-finished-result-value="<%= @board_finished_result %>"
>
...
/* ./app/javascript/controllers/board_controller.js */
export default class extends Controller {
static outlets = [ "store" ]
static values = {
finishedResult: { type: Object, default: {} }
}
connect() {
/* For testing. Remove when done. */
console.table(this.finishedResultValue)
...
}

When we test it in the browser, we can see the hash contents in the browser console.

Let’s ensure that when we click a tile, the correct contents of that tile from finishedResultValue is what is set instead of the current hardcoded values.

/* ./app/javascript/controllers/board_controller.js */
flip(event) {
...
if (tileIsOpen === "false") {
const currentTileContent =
this.#extractTileContentsFromFinishedResult(
tileIndex,
this.finishedResultValue
)
this.#showContent(tile, currentTileContent)
this.storeOutlet.addToSuccessiveTilesCollection(tile)
...
...
}
#showContent(tile, data) {
tile.dataset.tileIsOpen = "true"
tile.textContent = data.label
tile.style.backgroundColor = data.color
tile.classList.remove('bg-black')
}
...#extractTileContentsFromFinishedResult(tileIndex, finishedResultValue) {
const tileKeyInBoard =
Object
.keys(finishedResultValue)
.filter(key => {
return finishedResultValue[key].includes(tileIndex)
})
if (tileKeyInBoard.length < 1) {
return {color: null, label: null, indexes: [] }
}
const result = tileKeyInBoard[0].split("--")
return {
color: result[0],
label: result[1],
indexes: finishedResultValue[tileKeyInBoard[0]]
}
}

Now, each tile has the correct content based on the finishedResultValue.

Let’s handle not resetting successive tiles collection if the last clicked tile has the same contents as the tile being clicked.

/* ./app/javascript/controllers/store_controller.js */
...
connect(){
...
/* matchingIndex is the index of the tile with same contents */
this.lastClickedTile = { matchingIndex: null }
}
...updatePreviouslyClickedTile(currentTileIndex, indexes) {
this.lastClickedTile = {
matchingIndex: (indexes.filter(i => i != currentTileIndex)[0])
}
}
resetPreviousTile() {
this.lastClickedTile = { matchingIndex: null}
}
get tile() {
return this.lastClickedTile
}
/* ./app/javascript/controllers/board_controller.js */
...
  flip(event) {
const tile = event.target
const tileIndex = Number(tile.dataset.tileIndex)
const tileIsOpen = String(tile.dataset.tileIsOpen)
if (tileIsOpen === "false") {
const currentTileContent =
this.#extractTileContentsFromFinishedResult(
tileIndex,
this.finishedResultValue
)
this.#showContent(tile, currentTileContent)
this.storeOutlet.addToSuccessiveTilesCollection(tile)
if (this.storeOutlet.successiveTilesCollectionCount === 2) {
setTimeout(() => {
if (this.storeOutlet.tile.matchingIndex !== tileIndex) {
this.storeOutlet.successiveTilesCollection.forEach(tile => {
this.#hideContent(tile)
});
}
this.storeOutlet.resetSuccessiveTilesCollection()
this.storeOutlet.resetPreviousTile()
}, 1000)
} else {
this.storeOutlet.updatePreviouslyClickedTile(
tileIndex,
currentTileContent['indexes']
)
}
}
}

When you test, you will notice that when you open two matching tiles successively, the tiles are not reset.

And that concludes our game.

To sum up, Stimulus JS is a powerful Javascript framework that provides a simple and efficient way to create interactive and dynamic user interfaces. Its core features, including controllers, actions, targets, values and outlets allow developers to add behavior and functionality to specific parts of a page in a modular and maintainable way.

What next?

You can try adding the following features:

  • Improve the create_board method on Dashboard controller.
  • Add a timer counter.
  • Add a move counter.
  • Add a difficulty select tag level ie @board_size.
  • Add a congratulations message when the board is solved.
  • Replace the tile label with an image of the actual animal.
  • Use typescript.
  • Code cleanup and refactoring.
  • Try an 8 by 8 board.

Originally published at https://semaphoreci.com on April 25, 2023.

--

--

Semaphore
Semaphore

Written by Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com

No responses yet