25 Apr 2023 · Software Engineering

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

    13 min read
    Contents

    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
    
    ...
    
    private
    
    def 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.


    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Avatar
    Writen by:
    I'm a software developer with over 5 years' experience in designing, building and maintaining web applications. My main stacks are Rails and React, but am open to new opportunities and adventures.
    Avatar
    Reviewed by:
    I picked up most of my soft/hardware troubleshooting skills in the US Army. A decade of Java development drove me to operations, scaling infrastructure to cope with the thundering herd. Engineering coach and CTO of Teleclinic.