Tic-Tac-Toe in pure CSS and HTML.
Go to file
Felix Martin 33d503105b Add missing div for end message. 2019-07-30 08:42:33 -04:00
.gitignore First working version of CSS generation. Bot not that good yet. Haha. 2019-07-25 20:25:40 -04:00
readme.html Write readme. Refactor rules generation. 2019-07-28 17:54:25 -04:00
readme.md Write readme. Refactor rules generation. 2019-07-28 17:54:25 -04:00
rules.py Write readme. Refactor rules generation. 2019-07-28 17:54:25 -04:00
template.tictactoe.css Write readme. Refactor rules generation. 2019-07-28 17:54:25 -04:00
template.tictactoe.html Add missing div for end message. 2019-07-30 08:42:33 -04:00
tictactoe.css Write readme. Refactor rules generation. 2019-07-28 17:54:25 -04:00
tictactoe.html Add missing div for end message. 2019-07-30 08:42:33 -04:00
tictactoe.py Write readme. Refactor rules generation. 2019-07-28 17:54:25 -04:00


Pure CSS Tic-tac-toe Bot

Tic-tac-toe CSS is a static Tic-tac-toe bot in pure CSS without any JavaScript. I had the idea for this project when I learned the basics of CSS many years ago. For me the hard part of this project was to figure out a way to respond to different board constellations in CSS. Once I had understood how to make rule based decisions it was easy to generate the necessary HTML and CSS for the bot in Python using the Jinja template engine.

HTML and CSS Generation

Luckily, Žiga Miklič has created a dual player Tic-tac-toe version in pure CSS. Without his example it would have been hard for me to come up with a solution as elegant as his (or possibly a solution at all). The idea is to generate a complete Tic-tac-toe board for each turn. Each field of the board consists of an <input> tag to mark the field as checked and a <label> tag to visualize it. This Jinja template generates nine boards - one for each turn. The classes .field-n and .turn-n help to control the game flow while .row-n and .col-n place the field on the board.

{% for turn in range(9) %}
<!-- turn-{{turn}} -->
{% for row in range(3) %}
{% for col in range(3) %}
<input class="field-{{row * 3 + col}} row-{{row}} col-{{col}} turn-{{turn}}" 
id="block-{{turn}}-{{row}}-{{col}}" type="radio">
<label class="turn-{{turn}}" for="block-{{turn}}-{{row}}-{{col}}"></label>
{% endfor %}
{% endfor %}
{% endfor %}

At the start of the game the CSS rules hide all fields but .turn-0. For each turn the checked field moves to the front and the next board becomes visible. In fact, only every second board moves to the front because the other move is done by the bot (note that the z-index is incremented by two).

{% for turn in turns_player %}
.tic-tac-toe input.turn-{{turn}}:checked + label {
  cursor: default;
  opacity: 1.0;
  z-index: 10 !important;

.tic-tac-toe input.turn-{{turn}}:checked ~ .turn-{{turn + 2}} + label {
  z-index: {{turn + 2}};
  display: block;
{% endfor %}

Using the .turn and .field classes we can now tell the bot to make certain moves. For example, let's say the player marks the first field .field-0 in turn .turn-0 and the second field .field-1 in turn .turn-2. We do not want the player to win by marking all fields in the first row and tell the bot to mark the third field (.field-2) of that row.

input.turn-0.field-0:checked ~ input.turn-2.field-1:checked ~
input.turn-3.field-2 + label {
  display: block;
  cursor: default;
  opacity: 1.0;
  z-index: 10 !important;

We do not care which field has been checked in .turn-1 because the bot rule generation algorithm keeps track of the bot's moves. The CSS file contains a couple of other rules to create the Tic-tac-toe grid, pretty check marks, and a message for when the game has ended. Credits to this JS fiddle for teaching me how to make crosses in CSS. I was not aware of the rotate property. The next step is to generate the CSS rules for the bot.

Bot Rules Generation

There has to be a CSS rule for every possible sequence of moves by the human player. We define a Move as an integer two-tuple. The first element represents the turn and the second element the index of the field that is selected by the move.

Move = namedtuple("Move", ["turn", "field"])

We define a Rule as a two-tuple where the first element is a sequence of moves and the second element is the next move, i.e. the response to the player moves by the bot.

Rule = namedtuple("Rule", ["moves", "next_move"])

One might think that there are too many rules to cover all potential player moves statically. However, it can be shown that this is not an issue. Tic-tac-toe is a simple game with only 9! possible sequences of moves. In the first round there are nine free fields, then eight, then seven and so on. The bot can only select one of its available options per turn. Hence, the number of possible sequences further reduces to 9 * 7 * 5 * 3 * 1 = 945.

To compute the rules we implement a recursive procedure get_rules. The procedure takes a board, the current turn, a partial rule (a rule including all moves till this point), and a flag whether indicating if it is the bot's or the player's turn. The procedure terminates when either of the players has won or when the board is full. In either case we create a rule which we append to draw_rules and win_rules which we use to display a nice message at the end of the game.

If the game is still open and it is not the bot's turn we iterate over all potential moves by the player. For each move we make another call to get_rules with the new board and partial rule.

If it is the bot's turn we also iterate over all potential moves and call a procedure get_equity for every new potential board. We choose the move with the highest equity for us and the lowest equity for our opponent. For this move we then create a new final rule and call get_rules recursively with the updated board and the current rule (not the new rule!).

The procedure get_equity takes a board and a player and returns the expected value for the constellation. If the player wins the game the equity is one, if they lose it is minus one, and a draw means zero. If the game is undecided get_equity calls itself recursively with every potential move and the opposite player. The procedure assumes that the other player always selects the move that maximizes their equity. By choosing the move with the minimal equity for the other player the current player can maximize their own equity:

def get_equity(board, player):
    if player_won(board, player):
        return 1
    elif player_lost(board, player):
        return -1
    elif game_over(board):
        return 0

    other_player = PLAYER_2 if player == PLAYER_1 else PLAYER_1
    equities = []
    for field_index in get_unchecked_fields(board):
        new_board = get_new_board(board, field_index, player)
        equity = get_equity(new_board, other_player)
    return min(equities) * -1

To avoid recomputing of equities we use lru_cache from functools to cache existing equities. Once we have computed all rules we can feed them to Jinja to generate the respective CSS code and thus have generated a static Tic-tac-toe bot in pure CSS.

{% for rule in bot_move_rules %}
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~  
{% endfor -%}
input.turn-{{rule.next_move.turn}}.field-{{rule.next_move.field}} + label {
  display: block;
  cursor: default;
  opacity: 1.0;
  z-index: 10 !important;
{% endfor %}