iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💭

Implementing Tic-Tac-Toe with Only CSS

に公開
2022/01/30

The content of this article is useless or highly likely to be useless in practice.
I would appreciate it if you could view it as an extension of play.

Final Result

First, let's start with the final result.
Since it's Tic-Tac-Toe, the game ends when someone wins or it results in a draw.
The logic for determining victory, defeat, and draws is also handled with CSS.

*When I include the entire code on CodePen, I will explain while excerpting only the important parts. Please refer to CodePen when you need the complete code.

Implementation

Now, let's dive into the implementation.

Processing nine cells from the start can be overwhelming, so let's focus on a single cell first.

Drawing the Circle (O)

We will make it so that a circle (O) is drawn when a cell is clicked.
Normally, when changing styles via a click, you would use JavaScript to toggle classes or attributes. This time, however, we will maintain the state using only HTML and CSS.
You all know the method for holding state in HTML, right? That's right—let's use input.

pug
input(
  type='checkbox'
  name='cell-o'
  id='cell-o'
)
.game
  .board
    span.cell
      label.label(for='cell-o')
scss
// Drawing O
#cell-o:checked ~ .game .cell {
  &::before {
    content: "O";
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 50px;
    font-weight: 600;
  }
}

By using a pseudo-element on .cell, we draw a circle (O) only when the checkbox is :checked.

Drawing O or X

Next, we will make it so that either O or X can be drawn.
Since a cell will now have three states—O, X, or Empty—let's use radio buttons instead of a checkbox.
While it depends on the situation, generally using radio buttons instead of checkboxes makes managing three or more states easier.

pug
//- Changed from checkbox to radio
input(
  type='radio'
  name='cell'
  id='cell-o'
)
input(
  type='radio'
  name='cell'
  id='cell-x'
)
.game
  .board
    span.cell
      label.label.__o(for='cell-o')
      label.label.__x(for='cell-x')
scss
// Drawing O
#cell-o:checked ~ .game .cell {
  &::before {
    content: "O";
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 50px;
    font-weight: 600;
  }
}
// Drawing X
#cell-x:checked ~ .game .cell {
  &::before {
    content: "X";
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 50px;
    font-weight: 600;
  }
}

I've colored them red and blue for clarity. Clicking red draws O, while clicking blue draws X.
The HTML element has simply changed from checkbox to radio, and it's basically the same as when we were only drawing O.

Once we've reached this point, let's expand it to 9 cells. Since the input elements are not necessary for the actual display, we will hide them with visibility: hidden.
Since we are using Pug and Sass, implementing it while making good use of loops will allow for a concise description.

Implementing a Turn-Based System

Next, I want to implement a turn-based system.
Currently, each cell is split into left and right halves to differentiate between O and X.
By implementing a turn-based system, we will control it so that instead of splitting a cell, O and X are drawn alternately upon clicking.

As for the specific method, we will count the number of :checked input elements and adjust the z-index of the label elements.

scss
/// Realizing the turn-based system

// 1 radio button is selected → increase the z-index of X
input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 2;
}
// 2 radio buttons are selected → increase the z-index of O
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 3;
}

// After this, continue to alternate and increase the z-index based on the count of :checked
// ...

In the CodePen above, I've changed the border color every time the turn changes to make it easier to understand.
By stacking label elements with position: absolute and alternating their z-index according to the number of :checked radio buttons, you can achieve a turn-based system for O and X.

scss
// Prevent already marked cells from being clicked again
@for $i from 1 through 9 {
  #cell-#{$i}-o:checked ~ .game .cell.__#{$i} {
    &::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 100;
      visibility: visible;
    }
  }
}

Also, to avoid the hassle of a cell being clicked again after it has been marked, we cover the .cell that has been :checked with a pseudo-element.
This way, O and X will not change even if a single cell is clicked multiple times.

Win/Loss Determination

Once the winner is decided, I want to display the text "Victory: X" or "Victory: O" at that moment.

As for win/loss determination, there's no choice but to do it the hard way.
There are 8 patterns, as shown below. We will write out all patterns and reflect them in the CSS.
We'll prepare a .result element and display it when a win or loss is decided.

Winning conditions

scss
// When 1, 2, 3 are :checked
#cell-1-x:checked ~ #cell-2-x:checked ~ #cell-3-x:checked ~ .game .result {
  opacity: 1;
  visibility: visible;
  &__text {
    &::after {
      content: "Victory: X";
    }
  }
}

// When 4, 5, 6 are :checked
// ...
// When 7, 8, 9 are :checked
// ...
// Describe for all patterns

However, writing all of these out would be painful, so let's use a Sass array.
Since Sass can handle 2D arrays, we can write it as follows:

scss
// Display .result when either player wins
// Brute force all winning patterns
$pattern: ([1,2,3], [4,5,6], [7,8,9],[1,4,7],[2,5,8],[3,6,9],[1,5,9],[3,5,7]);
@each $cells in $pattern {
  // X wins
  #cell-#{nth($cells, 1)}-x:checked ~ #cell-#{nth($cells, 2)}-x:checked ~ #cell-#{nth($cells, 3)}-x:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "Victory: X";
      }
    }
  }
  // O wins
  #cell-#{nth($cells, 1)}-o:checked ~ #cell-#{nth($cells, 2)}-o:checked ~ #cell-#{nth($cells, 3)}-o:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "Victory: O";
      }
    }
  }
}

Draw Determination

Finally, we will implement the determination for a draw.
The condition for a draw is simple:

  • 9 cells are filled, i.e., 9 input elements are :checked.
  • No winner has been decided.

Since it's tedious to check whether a winner was decided or not, let's first consider only the case where all 9 cells are filled.

scss
// When 9 inputs are :checked
input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ .game .result {
  opacity: 1;
  visibility: visible;
  &__text {
    &::after {
      content: "Draw"
    }
  }
}

As mentioned above, when 9 cells are :checked, we display "Draw" using a pseudo-element.
Next, I'd like to consider the second factor—whether a winner has been decided—but actually, there is no need to do any further work.

Let me explain why.
First, if a winner is decided when 8 or fewer cells are filled, the .result should already be displayed, so we don't need to worry about that.
The case to consider is when a win is decided exactly as the 9th cell is filled. Here, let's re-examine the CSS.

scss
// When someone wins
#cell-#{nth($cells, 1)}-x:checked ~ #cell-#{nth($cells, 2)}-x:checked ~ #cell-#{nth($cells, 3)}-x:checked ~ .game .result {
  &__text {
    &::after {
      content: "Victory: X";
    }
  }
}
// When it's a draw
input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ .game .result {
  &__text {
    &::after {
      content: "Draw"
    }
  }
}

If you check this in a browser, you'll see that the selector for when a winner is decided has higher specificity.
Therefore, when both the draw and win states overlap, the pseudo-element for the win state takes priority.
As a result, the word "Draw" will only be displayed when no winner has been decided.

Thank you for sticking with me this far. That's the end of the implementation!

Complete Code

This is the same content as the CodePen at the beginning, but I'll post just the complete code here as well.

Complete Code
pug
.wrapper
  - for(var i = 1; i < 10; i++)
    input(
      type='radio'
      name='cell-' + i
      id='cell-'+i+'-o'
    )
    input(
      type='radio'
      name='cell-' + i
      id='cell-'+i+'-x'
    )
  .game
    .board
      - for(var i = 1; i < 10; i++)
        span.cell(class='__' + i)
          label.label.__o(for='cell-'+i+'-o' class='__'+i)
          label.label.__x(for='cell-'+i+'-x' class='__'+i)
    .result
      p.result__text
scss
$board-w:300;
$board-h:300;
.wrapper {
  position:relative;
  background-color: skyblue;
}
input[name^="cell-"] {
  position: absolute;
  visibility: hidden;
}
.game {
  position:relative;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}
.board {
  display: flex;
  flex-wrap: wrap;
  width: $board-w + px;
  height: $board-h + px;
  background-color: #fff;
}
.result {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  background-color: rgba(0,0,0,.8);
  opacity: 0;
  visibility: hidden;
  z-index: 100;
  &__text {
    color: #fff;
    font-size: 30px;
    font-weight: 600;
    &::after {
      content: "";
    }
  }
}
.cell {
  position: relative;
  display: inline-block;
  width: ($board-w/3) + px;
  height: ($board-h/3) + px;
  box-sizing: border-box;
  &:nth-child(3n),
  &:nth-child(3n - 1){
    border-left: 2px solid rgba(0,0,0,.5);
  }
  &:nth-child(n + 4){
    border-top: 2px solid rgba(0,0,0,.5);
  }
}
.label {
  position:absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  &.__o {
    z-index: 1
  }
  &.__x {
    z-index: 0
  }
}

// Implementation of the turn-based system
input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 2;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 3;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 4;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 5;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 6;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 7;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 8
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 9
}

@for $i from 1 through 9 {
  // Drawing O
  #cell-#{$i}-o:checked ~ .game .cell.__#{$i} {
    &::before {
      content: "O";
      position: absolute;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;
      font-size: 50px;
      font-weight: 600;
    }
    &::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      display: block;
      width: 100%;
      height: 100%;
      z-index: 100;
    }
  }
  // Drawing X
  #cell-#{$i}-x:checked ~ .game .cell.__#{$i} {
    &::before {
      content: "X";
      position: absolute;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;
      font-size: 50px;
      font-weight: 600;
    }
    &::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      display: block;
      width: 100%;
      height: 100%;
      z-index: 100;
    }
  }
}

// Brute force all winning patterns
$pattern: ([1,2,3], [4,5,6], [7,8,9],[1,4,7],[2,5,8],[3,6,9],[1,5,9],[3,5,7]);
@each $cells in $pattern {
  // X wins
  #cell-#{nth($cells, 1)}-x:checked ~ #cell-#{nth($cells, 2)}-x:checked ~ #cell-#{nth($cells, 3)}-x:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "Victory: X";
      }
    }
  }
  // O wins
  #cell-#{nth($cells, 1)}-o:checked ~ #cell-#{nth($cells, 2)}-o:checked ~ #cell-#{nth($cells, 3)}-o:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "Victory: O";
      }
    }
  }
}

// Draw
input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ .game .result {
  opacity: 1;
  visibility: visible;
  &__text {
    &::after {
      content: "Draw"
    }
  }
}

Conclusion

That was how to create a Tic-Tac-Toe game using only HTML and CSS!
By making clever use of input, we were able to build the game logic.
As mentioned earlier, I don't think the content of this article will be useful in many actual work situations.
However, exploring the possibilities of CSS can lead to new discoveries, so I want to continue studying useless technologies whenever I have the chance.

Discussion