Project 3: Tic-Tac-Toe

Project 3: Tic-Tac-Toe

November 29 2021

Introduction.

The goal of this project is to build a Tic Tac Toe game with Django and HTMX. This project is inspired by the official React tutorial. The idea is anything that can be built with React, could also be built with HTMX. You can see my similar React implementation and code here.

Something to keep in mind is that we have a powerful backend in Django. So, we can build a much more sophisticated version of tic-tac-toe than we could in React by itself. In this tutorial, we would implement a real game with a human user and a computer opponent.

Our goal is mastering HTMX, so our computer opponent wouldn't be very smart. Although, we can extend this tutorial to build a smart AI with a minimax algorithm. The Django/HTMX concepts will be the same, we will just need more Python code to determine the computer moves.

Here are our user stories:

  1. I can choose player X or O
  2. I can fill any empty part of the board with my player symbol, and can't change an already filled board piece
  3. A computer player plays against me with the opposite symbol
  4. The game can determine a winner if there is one, or a tie if there isn't one.

Here are what we will go through:

So let's get started!


Configuration

User Model

To start, let's clone our starter code, build our docker image, and run migrations. Also, create a super user and login with it through the admin.

git clone -b boilerplate --single-branch https://github.com/Jonathan-Adly/htmx-tictactoe.git

docker compose up -d --build
docker compose exec web python manage.py migrate

docker compose exec web python manage.py createsuperuser

Our starter code is a Django project with three applications.

The first is an accounts application that uses django-allauth for authentications. If you went through the previous tutorials (I highly recommend), it is the same. The only difference is we added a couple of new fields to our user model.

To represent the "symbol" that our users would use ( "X" vs. "O"), we added a Charfield with a default of "X".

The interesting part though was adding a nested ArrayField to represent our game board. ArrayField is a PostgreSQL specific field that allows us to store lists in our database.

We would represent our board with something like this.

board = [

[0,0,0],

[0,0,0],

[0,0,0]

]

Where 0 is an empty cell, 1 would be a cell filled by "X", and 2 would be a cell filled by "O". This allows us to use any symbol we want, not just "X" and "O". Also, ArrayField data types have to be the same. So we can't have an integer and a string in the same board.

When an X player picks a board cell, let's say the middle cell. Our board will change as such,

board = [[0,0,0][0,1,0][0,0,0]]

Then, the computer move might change the board as follow,

board = [[0,0,0][0,1,2][0,0,0]]

And so on.

There are three things we need when using an ArrayField.

  • A base_field that represents the type of data in the list.
    • For example, a list of [0,1,2] would have an IntegerField as its base. - Another list of ["a","b","c"] would have a CharField as its base field.
    • The base_field could be another ArrayField, giving us a nested ArrayField which we will use in our project to represent our board.
    • We can't have an array of [1, "a"], the type of data has to stay the same
  • If we are to give our field a default, it should be a callable or a function that returns a list. A default of [] is not recommended.
  • Lastly, if we decide to nest our arrays, PostgreSQL requires that the arrays be rectangular. In other words, you can't have one array with three values and another with one value.

Keeping all this in mind, here is our new user model code. It is already done for you, no need to copy it.

from django.contrib.postgres.fields import ArrayField
from django.contrib.auth.models import AbstractUser
from django.db import models


"""
Our Board should look like this initially = [[0,0,0],[0,0,0],[0,0,0]]
Then as players play, it should look like this = [[1,0,2], [1,2,0], [0,0,0]]
empty = 0
X = 1
O = 2
"""


class CustomUser(AbstractUser):
    def get_default():
        return [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

    player = models.CharField(max_length=1, default="X")
    board = ArrayField(ArrayField(models.IntegerField()), default=get_default)

Config application

The config application doesn't have any changes from our minimal boilerplate tutorial (you can read it here). We just added our tictactoe application to the APPLICATION_LIST and its URLS to the urls.py module.

Tic-Tac-Toe

Our last application has only 1 view/endpoint that returns a home template. As before, we are using a base template and extending it for use in our home. Also, we have a component directory that holds a navbar.

The base template has a CSS style tag in the head. This handles our game board look and feel. It is from the React official tutorial with no changes. The "include" block renders our navbar.

(if you have trouble following, I highly recommend going through the previous tutorials in that course. We already went through the step-by-step process for building those patterns).

The interesting part is in the home template. Here is how we are rendering the game board.


{% for i in request.user.board %}
    <div class="board-row">
    {% for j in i %}
    <button
    class="square"
    > 
    
    </button>
    {% endfor %}
    </div>
{% endfor %}

We are taking advantage of the magic of Django templates to dynamically render our game board based on the user board. The end result is a 3x3 grid with each cell containing a button(square).

Something like this.

Now that we went over our starter code. Let's go ahead and work on our user stories. You can see the code for this part here.


Human Player

X or O

Since we represented the symbol on our user model, the first user story is straightforward. We need to hit an endpoint, that will change the symbol (player field) from "X" to "O" and vice versa.

We would use HTMX, so we would return a component that holds the player symbol.

First, let's go ahead and make that component.

touch templates/components/game_info.html

Now, we will cut the game info div from our home template and paste it in our component.

<!-- cut this div from the home.html -->
<!-- paste in the game_info.html component -->
<div class="game-info mt-20">
    <div class="status"> 
    You are Player X. <a href= "#"> Change to O </a>
</div>
</div>

Now - let's go ahead and change our component to use HTMX.

<div class="game-info mt-20" id="game_info">
    <div class="status"> 
    You are Player {{request.user.player}}. 
    
    <button
    hx-get="{% url 'change_player' %}"
    hx-target= "#game_info"
    hx-swap = "outerHTML"
    >
     Change Your symbol </button>
</div>
</div>

That's the HTMX part. Now, let's work on the Django part, building our endpoint and our view to handle it.

Here is our urls.py

from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("change-player", views.change_player, name="change_player"), #new
]

And this our views.py

from django.contrib.auth.decorators import login_required

@login_required
def change_player(request):
    if request.user.player == "X":
        request.user.player = "O"
    else:
        request.user.player = "X"

    request.user.save()
    return render(request, "components/game_info.html")

That's it. We don't even have to define a template variable, as we are working with the request user.

Now, let's go ahead and test our functionality. It should look like this.

That was easy! Let's go ahead and commit our code, and move to our second user story.

The code for this section can be found here.

The "this" keyword

To build the next story, "I can fill any empty part of the board with my player symbol". We need to make a request to an endpoint that returns the user symbol.

Something like this:

<button
hx-get= "/play">

</button>

With the response being an "X" or an "O" - depending on the chosen symbol. We have two problems with that code as it is though. The first is that we don't know which square/button the user will click, so we don't know our target for the response.

Luckily, htmx has a simple solution for us, the "this" keyword. We can specify a hx-target of "this", which will target the DOM element that was clicked. The "this" keyword could be used exactly the same way as explicitly writing down the target. It works with any trigger and any swap strategy.

As a best practice though, I would recommend writing down your target and avoiding "this" if possible. Especially in the beginning if you are working with other people. For our use case "this" is the perfect solution.

So, our button could have something like this:

<button 
hx-get="play"
hx-target="this"

</button>

The second problem is that we will eventually need to figure out a winner, so with each move, we have to update our database. The question is, how do we relay the position of the clicked square to our backend? So we can update our board in the database.

For Loop Counter

For this, we will need some Django template magic. A template filter called "forloop.counter0". This template filter tells us the current iteration of a for loop. Let's go ahead and see it in action.

Let's add the forloop.counter0 to our buttons.

<button
    class="square"
    hx-get= "{% url 'play'%}"
    hx-taget= "this"
    > 
    {{forloop.counter0}}
    </button>

Now you can see that our buttons (squares) are filled with the columns numbers. We have three columns, so the first column is "0", the second column is "1", and the third column is "2".

What about our rows? We need both columns and rows to update our database.

Well - we have another magical Django template filter for that "forloop.parentloop". The forloop.parentloop filter allows us to access the parent loop in a nested loop. So, to get the current iteration for our parent loop, we can write something like that,

<button
    class="square"
    hx-get= "{% url 'play'%}"
    hx-taget= "this"
    > 
    <!-- cols --> 
    {{forloop.counter0}}
    <!-- rows -->
    {{ forloop.parentloop.counter0 }}
    </button>

To make it even more obvious, let's scale up our squares and write it down.

In base.html, let's change the CSS of square as follow. We are only changing the width and the height

  .square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 184px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 184px;
  }

Then, let's put this in our buttons.

 <!-- cols --> 
    Col: {{forloop.counter0}}
    <!-- rows -->
    Row: {{ forloop.parentloop.counter0 }}

Rows and Columns

You should see something like this.

Now, that we know how to numerically represent the position of the square, it's a matter of passing that data to the backend. We can do so via a form and hidden inputs, or a GET request with URL parameters.

We will go with the form approach, as a technical user might type the URL manually and break the game.

First, let's return the square back to the way it was.

  .square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
  }

Then, we will go ahead and have the square coordinate to be hidden inputs to a form.

{% for i in request.user.board %}
    <div class="board-row">
    {% for j in i %}
        <form 
        hx-post="{% url 'play' %}"
        hx-target="this"
        hx-swap="outerHTML"
        >
 <!-- hx-trigger default is submit, the square button is a submit button -->
        <input type="hidden" name="row" value={{forloop.parentloop.counter0}}>
        <input type="hidden" name="col" value={{forloop.counter0}}>
        <button class="square"> </button>
    </form>
    {% endfor %}
    </div>
{%endfor %}

Next - we will do the Django part. Building the endpoint and the view to handle it.

# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("change-player", views.change_player, name="change_player"),
    path("play", views.play, name="play"), #new
]

# views.py
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST

@require_POST
@login_required
def play(request):
    if request.user.player == "X":
        player = 1
    else:
        player = 2
    # make sure to convert the data passing through to integers
    row = int(request.POST["row"])
    col = int(request.POST["col"])
    request.user.board[row][col] = player
    request.user.save()
    return render(request, "components/square.html")

In this view, we are returning a new square component. Basically, a square button without the form. So clicking on it doesn't do anything.

Squares

Let's go ahead and build that component.

touch templates/components/square.html

<button class="square"> 
{{request.user.player}}
</button>

While we are in this user story, let's go ahead and build a game reset link. The next part has lots of moving pieces. So, resetting the game quickly will make our life easier.

In home.html,

{% include 'components/game_info.html' %}
<!-- new -->
<a href= "{% url 'reset' %}"> Reset the game </a>
<!-- end new -->

And here is the URL and the view for the reset link.

urlpatterns = [
   ...
   #nothing else changes
    path("reset", views.reset, name="reset"),
]
from accounts.models import CustomUser #new

@login_required
def reset(request):
    request.user.board = CustomUser.get_default()
    request.user.player = "X"
    request.user.save()
    return redirect("home")

If you want, you can try and see how can you convert the reset link to HTMX on your own. It should be straightforward.

hx-swap-oob

Finally, the last thing we will do before finishing up this user story is take away the "change your symbol" button when the game starts.

To do this, we will use another very helpful htmx attribute when we need to replace a DOM element outside our target element. hx-swap-oob ("oob" stands for out of band).

Unlike our previous htmx attributes, hx-swap-oob goes in the HTML component that goes back (the response) after a request. The element that has hx-swap-oob attribute replaces whatever element we specify that is currently in the DOM.

Our response component is square.html, so let's change it as follows.

<button class="square"> 
{{request.user.player}}
</button>

<!-- we are replacing an element with #change_symbol_button w/ this div. -->
<!-- default swap startegy when true is used outerHTML -->

<div id="change_symbol_button" hx-swap-oob="true">

</div>

Here, we are saying that when the square component comes back from the server, it will do two things. First, it will replace the target element of the htmx request with a button containing the player symbol. Second, it will replace whatever element has the id "change_symbol_button" with an empty div. In other words, we are replacing an element with content with an element without content.

To finish up, we need to give our button that id "change_symbol_button" since this is the element we want to change.

<!-- in components/game_info.html -->
 <button
    id="change_symbol_button"
    hx-get="{% url 'change_player' %}"
    hx-target= "#game_info"
    hx-swap = "outerHTML"
    >
     Change Your symbol </button>

Now, go ahead and try it. You should see this.

hx-swap-oob is quite flexible. Its value could be "true" if we want to replace an element that has the same id with an outerHTML swap (defaults). But it could also be something like this hx-swap-oob= "innerHTML:#random_element". This will replace the inner HTML of the DOM element #random_element. Any swap value could be used, and any DOM element could be targeted.

Our user story is done! let's go ahead and commit our code.

You can see my code here.


Computer Opponent

"A computer player plays against me with the opposite symbol"

Our next user story calls for the computer to take a turn. Now, to do this we need to

  1. Programmatically decide where the computer will play.
  2. Save that move in our database.
  3. Return a DOM element to replace the square in that position with the right symbol.

The computer move

For the first part, we don't really need a smart AI for this tutorial. So, we will go ahead and just have the computer play at the first open spot it sees.

Remember our board is represented like this.

[

[0,0,0],

[0,0,0],

[0,0,0],

]

So, our computer move would be the first "0" it sees. I am going to separate that logic in its own module, so if you want to implement minimax and have a real AI you can just change that module.

touch tictactoe/utils.py

# in tictactoe/utils.py
def computer_move(user):
    board = user.board
    for row in board:
        for col in board:
            if col == 0:
                pass #we decided where the computer will play here.
    # if there are no zeroes left, squares are all full           
    return "game board is full"

Enumerate

For the second part, the goal is to save the computer move in our board.  To do that we need to find out the position (row/col) of that move (i.e, the first zero in our board). Once we do, we will change it from 0 to 1 or 2. We can find out the position (index) of our target spot using enumerate. Here is the code,

# in tictactoe/utils.py
def computer_move(user):
    board = user.board
    if user.player == "X":
        computer = 2  # computer symbol is O, which is 2
    else:
        computer = 1  # computer symbol is X, which is 1

    for index, row in enumerate(board):
        for idx, col in enumerate(row):
            if col == 0:
                user.board[index][idx] = computer
                user.save()
                return None
    # game is full
    return "game board is full"

If you need to visualize the loops or experiment with the logic, I made a repl with print statements here.

Expanding the target

The last part is the fun part, where we use htmx.

As before, we have an action from the user that is going to change DOM elements outside the target element. The user clicks on one square, but we want to change another.

Previously, we used hx-swap-oob which is an excellent way to change small parts of our applications. It works best when the state of the application is simple and changes in a predictable way. For example, whether the user symbol is "X" or "O".

If you ever developed with React (or Vue), the idea of "state" should be familiar. If you didn't, the "state" represents information about a component's current situation.

For example, our square's state could be empty or filled with an "X", or an "O". Our user state could be logged in or logged out. Our game state could be new, in progress, or finished.

If we take the board as a whole, its state is quite complex and unpredictable. We have 9 squares in the board, each with 3 possible values. In that case, hx-swap-oob wouldn't really work.

As a React developer would say, we need to "lift up our state" from the square to the whole board. In other words, when a user clicks the square we have to update the whole state of our board, not just the square they clicked. The htmx docs call that expanding the target.

Basically, we are going to return the board as a component after each move, not just the square. So, let's make a new file in our component directory.

touch templates/components/board.html

Then, let's cut this part from the home template and paste it there.

<!-- in templates/components/board.html -->
{% for i in request.user.board %}
    <div class="board-row">
    {% for j in i %}
        <form 
        hx-post="{% url 'play' %}"
        hx-target="this"
        hx-swap="outerHTML"
        >
        <input type="hidden" name="row" value={{forloop.parentloop.counter0}}>
        <input type="hidden" name="col" value={{forloop.counter0}}>
        <button class="square"> </button>
    </form>
    {% endfor %}
    </div>
{%endfor %}

So far, our target still uses the "this" keyword and refers to the square that was clicked. Let's expand our target to the whole board.

<!-- in board.html -->
<div id="board"> <!-- new -->
{% for rows in request.user.board %} <!-- rows is better than "i" -->
    <div class="board-row">
    {% for col in rows %} <!-- col is better than "j" -->
    <!-- target have changed -->
        <form 
        hx-post="{% url 'play' %}"
        hx-target="#board"
        hx-swap="outerHTML"
        >
        <input type="hidden" name="row" value={{forloop.parentloop.counter0}}>
        <input type="hidden" name="col" value={{forloop.counter0}}>
        <button class="square">
        <!-- everytime we render the board, we will update it based on our state -->
        {% if col == 1 %}
            X
        {% elif col == 2 %}
            O
        {% endif %}
        </button>
    </form>
    {% endfor %}
    </div>
{% endfor %}
</div>

Another change that we did is wrap the whole component in its own div. We will swap that div after each play. Also, we changed our loops from the generic "i" and "j" to better descriptors.

Finally, one edge case that we didn't handle before was when the user refreshes the page in the middle of the game. By adding conditional rendering inside the square, we can sync what's in our database to what's in the board on every request or partial rendering.

The next part would be deleting the square component since we are returning our whole board now. Before we do so, you will notice that we have our hx-swap-oob there. We need to fix that so we can keep our old functionality the same.

Copying and pasting into the board component wouldn't work. Since we will end up with it rendered on the initial page load, and we will have two components with the same id. We can tinker with it further and make it work, but we will implement even a better solution.

Eventually, we will need to check the game status after each time the user or the computer play. The game "state" could be new, in progress, finished with a winner (X or O), or finished with no winner. That goes in the "complicated and not predictable" bucket, so we need to ditch hx-swap-oob and use something more sophisticated.

Response headers

What we would do is use another htmx technique to handle updating DOM elements outside the target. Response headers.

The idea here is that we would return a special response header from the server. The appearance of that header would be an htmx trigger for the DOM element we want updated.

Here is how we will change our game_info.html component.

<!-- templates/components/game_info.html -->
<!-- our #game-info element would be updated 
on the appearance of the checkGameStatus header.
It would be replaced by the response 
from a GET request to the "game_status" URL 
-->
<div class="game-info mt-20" 
id="game_info"
hx-trigger="checkGameStatus from:body"
hx-target="#game_info"
hx-get = "{% url 'game_status' %}"
hx-swap = "outerHTML"
>
    <div class="status"> 
    You are Player {{request.user.player}}. 
    <!-- status will be included in the response 
    coming from the "game_status" URL -->
    
    {% if status %}
     <p> Status: {{status}} </p>
    {% else %}
        <button
    hx-get="{% url 'change_player' %}"
    hx-target= "#game_info"
    hx-swap = "outerHTML"
    hx-trigger= "click"
    >
     Change Your symbol </button>

    {% endif %}

    </div>

</div>  

Now - our htmx part is done! Let's go ahead and build our URLs and views in Django. You can also delete the square component now.

We only added one extra URL endpoint, the game_status one.

from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("change-player", views.change_player, name="change_player"),
    path("play", views.play, name="play"),
    path("reset", views.reset, name="reset"),
    path("game-status", views.game_status, name="game_status"), #new
]

For our views, we are going to change our "play" function. We need to handle the computer move, return the response header to the check game status, and return the board component instead of the square.

We also need to build our view to return the game status.

Here is how our play function changed.

from .utils import computer_move

@require_POST
@login_required
def play(request):
    """
    This view first processes the user move and saves it. Then, process the computer move
    and saves it. Finally, it returns the new board with the 2 moves to the front end with
    a response header. The response header would trigger a request to check the game status
    which affects a different element in the DOM.
    """
    # the user move
    if request.user.player == "X":
        player = 1
    else:
        player = 2
    row = int(request.POST["row"])
    col = int(request.POST["col"])
    request.user.board[row][col] = player
    request.user.save()

    # the computer move
    computer_move(request.user)
    # the response with the header
    response = render(request, "components/board.html")
    response["HX-Trigger"] = "checkGameStatus"
    return response

And here is our new game_status function.

def game_status(request):
    """
    we hit this view on each move to check what is the game status.
    The game could a new game, in progress, finished with a winner or finished with a tie.
    """
    # first, we check if game is in progress
    for row in request.user.board:
        for col in row:
            if col == 0:
                # TO DO HERE: Check if there is a winner. We will change the status to the winner.
                status = "game is in progress"  # no winner yet
                return render(request, "components/game_info.html", {"status": status})
    # no winners, and game board is full
    return render(request, "components/game_info.html", {"status": "game is tied"})

If something is not working, here are a few debugging tips.

  1. Open your developer console then go to the network tap. It should show you what requests are you making.
  2. Under network, you can go to the headers tab and see what is included in your response headers. You should see something like the picture.
  3. Remember to get comfortable using docker compose logs - it gives you detailed error messages if something is not working.
  4. If you need to, you can download this package. It has a middleware where if something breaks, it presents you with a nice debugging page.

Now - go ahead and test your application making sure everything works as it should.

Edge cases

There are two edge cases we need to handle though before finishing this user story.

Go ahead and refresh the page. You will notice that the "change the your symbol" button shows up again.

The reason why this happens is that when we render our home template, it has no access to our game status. So, to fix it we just need to give our home template that state.

We don't want to write the same code twice (in the home view and the game_status view), so let's also put the logic in our utils.py module. Here is our updated module.

#in tictactoe/utils.py
def check_status(board):
    # if check_winner(board):
    # status= "--- have won the game"
    # return and terminate here
    flat_list = []
    for row in board:
        for col in row:
            flat_list.append(col)

    if 1 in flat_list or 2 in flat_list:
        if 0 in flat_list:
            return "game in progress"
        else:
            return "game has no winner"

    else:
        return None

And here are our new views,

from .utils import computer_move, check_status


def home(request):
    """
    If game didn't start, we will render home.html with status as None.
    If game did start and user hits refresh, we need to update the game status.
    """
    status = check_status(request.user.board)
    return render(request, "home.html", {"status": status})

...
#nothing else changed
def game_status(request):
    """
    we hit this view on each move to check what is the game status.
    The game could be a new game, in progress, finished with a winner or finished with a tie.
    """
    status = check_status(request.user.board)
    return render(request, "components/game_info.html", {"status": status})

That's the first edge case.

The second edge case is even when our square is filled, the button and the form stay active. So, our user can click on the box that the computer filled and change it to their symbol.  

To fix this, we just need to slightly adjust our conditional rendering inside our board and disable the button. Like this,

<button
       class="square"
        {% if col != 0 %} disabled {% endif %}>
        
        {% if col == 1 %}
            X
        {% elif col == 2 %}
            O
        {% endif %}
        </button>

Also, feel free to move the button or the form in its own component. A big bonus of htmx is that if you are working in a team of multiple people, you can isolate components so specific people can own them.

Last thing, we need to add a new CSS property so our disabled buttons don't look different.

<!-- in base.html -->
.square:disabled {
    color:inherit
  }

Our user story is now done. Everything works and we handled all the edge cases.

Go ahead and commit your code. You can see my code here.


Winner

"The game can determine a winner if there are one, or a tie if there aren't one."

The next part has no HTMX.  Just a couple of Python functions, where we take advantage of set().

First, we make a set of each row and check its length. If it contains only one element (that is not our default 0), then that element is our winner.

We do the same for our diagonals. Where we want to check the positions "0,0", "1,1", and "2,2" in our board in a set. Then, the positions "0,2", "1,1", and "2,0".

We would also need NumPy to transpose our board. That will turn our columns into rows, so we can check columns this way.

docker compose exec web pipenv install numpy
docker compose down
docker compose up -d --build

No need to worry if it is too complicated. It's not really the goal of this tutorial. Here is the code which we will put in our utils.py module.

#nothing else changed in utils.py
import numpy as np

def check_rows(board):
    for row in board:
        if len(set(row)) == 1:
            if row[0] == 1:
                return "X"
            elif row[0] == 2:
                return "O"
            else:
                return row[0]
    return 0


def check_diagonals(board):
    if len(set([board[i][i] for i in range(len(board))])) == 1:
        if board[0][0] == 1:
            return "X"
        elif board[0][0] == 2:
            return "O"
        else:
            return board[0][0]
    if len(set([board[i][len(board) - i - 1] for i in range(len(board))])) == 1:
        if board[0][len(board) - 1] == 1:
            return "X"
        elif board[0][len(board) - 1] == 2:
            return "O"
        else:
            return board[0][len(board) - 1]
    return 0


def check_winner(board):
    # transposition to check rows, then columns
    for new_board in [board, np.transpose(board)]:
        result = check_rows(new_board)
        if result:
            return result
    return check_diagonals(board)

And that's how we check for our winner. Basically, running "check_winner" would either return 0 (no winner) or a winner.

The next step is simply hooking our check_winner function into our game_status workflow.

Here is our new game_status function (also in utils.py).

def check_status(board):
    if check_winner(board) != 0: #new
        status = f"The {check_winner(board)} player have won the game"
        return status
    #that part didn't change
    flat_list = []
    for row in board:
        for col in row:
            flat_list.append(col)

    if 1 in flat_list or 2 in flat_list:
        if 0 in flat_list:
            return "game in progress"
        else:
            return "game has no winner"

    else:
        return None

That's it. Go ahead and test all possible winning combinations. All of them should work. Our user story now is done.

Let's go ahead and commit our code. Our user stories are now complete. You can see my code here.

On your own

If you have made it this far in these series/tutorials, you should be very comfortable with HTMX. There are two additional weird behaviors that we should squish before calling our application complete.

The first is that the computer plays at the same exact time as our user with no delay. Ideally, we would add a 300ms delay with a "thinking..." text somewhere, then the computer would make its move.

The second is even after a player has won. The forms (squares) are active, and the user can still play. If a user won, the computer still makes their move. We should disable the forms once the game has been won.

Here are the scoped user stories:

  1. I don't want the computer to make its move at the same exact time as me. I want to emulate a realistic human response with a slight delay. I should also see when the computer is "thinking" about its move.
  2. I want to disable the forms once the game finishes with a winner.

Here is a video of the final product.

If you feel comfortable, you should be able to implement these user stories on your own.

If you get stuck, I have the finished project (with some component refactoring) here. But, I would really recommend trying this on your own first.

Conclusion

In this project, we built a complete Tic-Tac-Toe game using Django and HTMX. We learned about the keyword "this" and when to use it.

Also, we went through the various htmx techniques we can use to update DOM elements outside of our htmx target. We can use hx-swap-oob for small, predictable changes. Expand our target, when we need to sync the frontend to a constantly changing global state. Lastly, we used response headers to frequently check on an endpoint and inquire about a specific item state.

Get notified about new HTMX/Django tutorials
You will only get an email whenever a post or a course is published!

Copyright © HTMX-Django 2021.

Built by Jonathan Adly. Contact for opportunities.