So let’s get started !

Introduction

To start, let’s clone our starter code, build our docker image, and run migrations.

Edit docker-compose version: “3.3”

git clone -b boilerplate --single-branch https://github.com/Jonathan-Adly/htmx-tictactoe.git
✦ ❯ git diff
diff --git a/docker-compose.yml b/docker-compose.yml
index 9f65e04..1766eb8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,4 @@
-version: "3.9"
+version: "3.3"
docker-compose up -d --build
 docker ps
CONTAINER ID   IMAGE                      COMMAND                  CREATED          STATUS          PORTS                                       NAMES
33e99a10e6b4   htmx-tictactoe_web         "python manage.py ru…"   11 seconds ago   Up 11 seconds   0.0.0.0:8000->8000/tcp, :::8000->8000/tcp   htmx-tictactoe_web_1
23cdc5808dd3   postgres:11                "docker-entrypoint.s…"   14 minutes ago   Up 11 seconds   5432/tcp                                    htmx-tictactoe_db_1
docker-compose exec web python manage.py migrate
❯ docker-compose exec web python manage.py migrate
Operations to perform:
Apply all migrations: account, accounts, admin, auth, contenttypes, sessions, sites, socialaccount
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying account.0001_initial... OK
Applying account.0002_email_max_length... OK
Applying accounts.0002_auto_20211101_0104... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying sessions.0001_initial... OK
Applying sites.0001_initial... OK
Applying sites.0002_alter_domain_unique... OK
Applying socialaccount.0001_initial... OK
Applying socialaccount.0002_token_max_lengths... OK
Applying socialaccount.0003_extra_data_default_dict... OK

http://0.0.0.0:8000/accounts/signup/

../../../../../../_images/signup.png

http://0.0.0.0:8000/accounts/signup/

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.

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.

accounts/models.py

Config application

The config application doesn’t have any changes from our minimal boilerplate tutorial (you can read it modern-django-boilerplate ( https://htmx-django.com/blog/a-minimalistic-modern-django-boilerplate )).

We just added our tictactoe application to the APPLICATION_LIST and its URLS to the urls.py module

config/settings.py

  1"""
  2Django settings for config project.
  3
  4Generated by 'django-admin startproject' using Django 3.2.8.
  5
  6For more information on this file, see
  7https://docs.djangoproject.com/en/3.2/topics/settings/
  8
  9For the full list of settings and their values, see
 10https://docs.djangoproject.com/en/3.2/ref/settings/
 11"""
 12
 13from pathlib import Path
 14
 15# Build paths inside the project like this: BASE_DIR / 'subdir'.
 16BASE_DIR = Path(__file__).resolve().parent.parent
 17
 18
 19# Quick-start development settings - unsuitable for production
 20# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
 21
 22# SECURITY WARNING: keep the secret key used in production secret!
 23SECRET_KEY = "django-insecure-(&r4r!@4m*dl^bwdo4g)h+v+3ba=ck2gx(0^@_@vsydhaj%rr^"
 24
 25# SECURITY WARNING: don't run with debug turned on in production!
 26DEBUG = True
 27
 28ALLOWED_HOSTS = ["0.0.0.0"]
 29
 30
 31# Application definition
 32
 33INSTALLED_APPS = [
 34    "django.contrib.admin",
 35    "django.contrib.auth",
 36    "django.contrib.contenttypes",
 37    "django.contrib.sessions",
 38    "django.contrib.messages",
 39    "django.contrib.staticfiles",
 40    "django.contrib.sites",
 41    "allauth",
 42    "allauth.account",
 43    "accounts",
 44    "allauth.socialaccount",
 45    "tictactoe",
 46]
 47
 48MIDDLEWARE = [
 49    "django.middleware.security.SecurityMiddleware",
 50    "django.contrib.sessions.middleware.SessionMiddleware",
 51    "django.middleware.common.CommonMiddleware",
 52    "django.middleware.csrf.CsrfViewMiddleware",
 53    "django.contrib.auth.middleware.AuthenticationMiddleware",
 54    "django.contrib.messages.middleware.MessageMiddleware",
 55    "django.middleware.clickjacking.XFrameOptionsMiddleware",
 56]
 57
 58ROOT_URLCONF = "config.urls"
 59
 60TEMPLATES = [
 61    {
 62        "BACKEND": "django.template.backends.django.DjangoTemplates",
 63        "DIRS": [str(BASE_DIR.joinpath("templates"))],
 64        "APP_DIRS": True,
 65        "OPTIONS": {
 66            "context_processors": [
 67                "django.template.context_processors.debug",
 68                "django.template.context_processors.request",
 69                "django.contrib.auth.context_processors.auth",
 70                "django.contrib.messages.context_processors.messages",
 71            ],
 72        },
 73    },
 74]
 75
 76WSGI_APPLICATION = "config.wsgi.application"
 77
 78
 79# Database
 80# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
 81
 82DATABASES = {
 83    "default": {
 84        "ENGINE": "django.db.backends.postgresql",  # changed from sqlite
 85        "NAME": "postgres",  # development settings, will change in production
 86        "USER": "postgres",  # development settings, will change in production
 87        "PASSWORD": "postgres",  # development settings, will change in production
 88        "HOST": "db",
 89        "PORT": 5432,
 90    }
 91}
 92
 93# Password validation
 94# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
 95
 96AUTH_PASSWORD_VALIDATORS = [
 97    {
 98        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
 99    },
100    {
101        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
102    },
103    {
104        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
105    },
106    {
107        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
108    },
109]
110
111
112# Internationalization
113# https://docs.djangoproject.com/en/3.2/topics/i18n/
114
115LANGUAGE_CODE = "en-us"
116
117TIME_ZONE = "UTC"
118
119USE_I18N = True
120
121USE_L10N = True
122
123USE_TZ = True
124
125
126# Static files (CSS, JavaScript, Images)
127# https://docs.djangoproject.com/en/3.2/howto/static-files/
128
129STATIC_URL = "/static/"
130
131# Default primary key field type
132# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
133
134DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
135# in boilerplate/config/settings.py
136# all auth settings
137
138SITE_ID = 1  # all-auth supports multiple sites
139
140
141AUTHENTICATION_BACKENDS = [
142    "django.contrib.auth.backends.ModelBackend",  # this how admins will log in to the admin site
143    "allauth.account.auth_backends.AuthenticationBackend",  # this how users log in
144]
145LOGIN_REDIRECT_URL = "home"  # change to desired url name
146LOGOUT_REDIRECT_URL = "home"  # change to desired url name
147ACCOUNT_SESSION_REMEMBER = True  # remember user via sessions
148ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False  # preferred UX
149ACCOUNT_USERNAME_REQUIRED = False  # preferred UX
150ACCOUNT_AUTHENTICATION_METHOD = "email"
151ACCOUNT_EMAIL_REQUIRED = True  # required for email authentication
152ACCOUNT_UNIQUE_EMAIL = True  # required for email authentication
153ACCOUNT_EMAIL_VERIFICATION = "optional"  # can use as a welcome email as well
154ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 5
155ACCOUNT_LOGIN_ATTEMPTS_LIMIT = 5
156ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT = 86400
157ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
158AUTH_USER_MODEL = "accounts.CustomUser"
159
160# email
161# email will go to console for now, need to change in production
162EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

config/urls.py

1from django.contrib import admin
2from django.urls import path, include
3
4
5urlpatterns = [
6    path("admin/", admin.site.urls),
7    path("accounts/", include("allauth.urls")),
8    path("", include("tictactoe.urls")),
9]

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.

../../../../../../_images/tictactoe.png

Now that we went over our starter code. Let’s go ahead and work on our user stories. You can see the code ( https://github.com/Jonathan-Adly/htmx-tictactoe/tree/boilerplate ) for this part here.