Build a Memory Game with Rails, Stimulus JS, and Tailwind CSS
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]
andgrid-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
withbg-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.