Creating a simple tic-tac-toe game with Django / Channels / DRF / Celery and Typescript.

Part 1 of 4

·

5 min read

Introduction

This is the first of four parts on creating a simple game based on Python and Typescript. I and my colleague worked on our small pet project (online turn-based fighting) and I think, our experience could be useful for someone else. We will start from scratch and in the end we will have online multiplayer game with simple matchmaking and bots. We don’t have a big number of player in our game, so I don’t have proofs that our solution is working with big loading, but it should be good for horizontal scaling.

First step

In my article, I will provide only our-app-specific code and installation details. Otherwise, this article will contain many duplicate guides from other places. So, if in the article you will see: Install redis Please open google.com and search for “How to install redis your os name” Let’s check you — Install Django ;) After Django will be installed we need to create two apps, run next commands:

python manage.py startapp players

python manage.py startapp match

Now we need to install Channels pip install -U channels channels.readthedocs.io/en/stable/installat.. they have a good tutorial on how to integrate it to Django project. You can use it, or just check source code of this project on github. Also, we will need to install redis for channels to use to communicate between connections. pip install channels_redis And update settings.py to use redis

CHANNEL_LAYERS = {
  'default': {
    'BACKEND': 'channels_redis.core.RedisChannelLayer',
    'CONFIG': {'hosts': [('127.0.0.1', 6379)]},
  },
}

Please make sure, you have installed and run redis service, you can find how to do that here redis.io/topics/quickstart To communicate between frontend and backend we will use JSON and we will use DRF to serialize/deserialize/validate data. Installation is also straight-forward: django-rest-framework.org/#installation

Prepare data models

We will use the next models in our app:

class Player(models.Model):
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  name = models.CharField(max_length=50, unique=True)

We will not use any passwords for players, just the name.

class Match(models.Model):
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  players = models.ManyToManyField('players.Player')
  started = models.BooleanField(default=False)

Our next step is to create migrations for new models and apply them (this action will also apply all existed migrations) python manage.py makemigrations

python manage.py migrate

Let’s check that our app is working. Create templates/index.html to provide a start point for users. Create a view to render this template to user: in match/views.py add next code:

def index(request):
  return render(request, ‘index.html’)

And add this view to urls.py

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', index),
]

Run the server and open in browser localhost:8000 you should see your index.html

XHR endpoints

We will use xhr requests to one direction communication, when client needs to retrieve any details based on events, for example: user login, open a new screen, click on the button, etc.

/join endpoint Something like authentication, this endpoint will receive name and will create a new user or return an existing one. We will use user.id as an authentication token to sign other requests. Create a players/serializers.py file — we will use it to store serializers for DRF. Let’s write our first serializer for the Player model, it will be pretty simple:

class PlayerSerializer(serializers.ModelSerializer):
  class Meta:
    model = Player
    fields = '__all__'

This serializer takes all model fields and outputs them to a specific format (JSON in our case). Now we need to add a view to process user requests:

class JoinView(APIView):
  def post(self, request):
    player, created = Player.objects.get_or_create(name=request.data[“name”])
    return Response(PlayerSerializer(instance=player).data)

We can use standard Django View, but DRF provides APIView class that makes working with XHR requests pretty simple. The last step — add this view to urls.py

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', index),
  path('players/join', JoinView.as_view()),
]

/start endpoint This endpoint starts matchmaking for the user. Logic will be very simple — we are looking for matches with players count = 1 and started flag is False. If match is not found — we create a new one. Creating a serializer for Match model match/serializers.py

class MatchSerializer(serializers.ModelSerializer):
  players = PlayerSerializer(read_only=True, many=True)

  class Meta:
    model = Match
    fields = '__all__'

It’s very similar to PlayerSeriazlier except one thing, we want to provide detailed information for each user when returning information about the match.

Create view match/views.py

class StartView(APIView):
  def post(self, request):
    player_uuid = request.headers.get(“Player-Id”, None)
    player = Player.objects.get(id=player_uuid)
    match = Match.objects.annotate(players_count=Count(‘players’)).filter(started=False, players_count=1).first()
    if not match:
      match = Match.objects.create()
    match.players.add(player)
    return Response(MatchSerializer(instance=match).data)

and add it to urls:

urlpatterns = [
  path(‘admin/’, admin.site.urls),
  path(‘’, index),
  path(‘players/join’, JoinView.as_view()),
  path(‘match/start’, StartView.as_view()),
]

These two endpoints are enough to log in and start the match. Now we will create an endpoint for the websocket. After the user starts the match, server will return match.id, we will use id to determine match where user is connecting. In Channels — views are calling Consumer, so we need to add our first consumer match/consumers.py

class MatchConsumer(AsyncWebsocketConsumer):
  async def connect(self):
    match_id = self.scope[“url_route”][“kwargs”][“match_id”]
    match = await get_match(match_id)
    if match.started: 
      raise Exception(“Already started”)
    players_count = await get_players_count(match_id)
    if players_count > 1: 
      raise Exception(“Too many players”)
    await add_player_to_match(match_id, self.scope[“player”])
    self.match_group_name = “match_%s” % match_id
    await self.channel_layer.group_add(self.match_group_name, self.channel_name)
    await self.accept()

  async def disconnect(self, close_code):
    await self.channel_layer.group_discard(self.match_group_name, self.channel_name)

  async def receive(self, text_data):
    text_data_json = json.loads(text_data)
    message = text_data_json[“message”]
    await self.channel_layer.group_send( self.match_group_name, {“type”: “chat_message”, “message”: message})

  async def chat_message(self, event):
    message = event[“message”]
    await self.send(text_data=json.dumps({“message”: message}))

In connect method we are looking for match that user connecting, then checking that user can join the match. If all is ok — we are adding user to a specific group that we will use to send messages about the match (round start, round end, win, lose, etc) receive and chat_message I have copied content from Channels guide, we will update it later. As you can see, in connect method we are using self.scope[“player”], we need to provide “player” to the scope. To do that we need to create custom auth middleware players/​​playerauth.py

@database_sync_to_async
def get_player(player_id):
  return Player.objects.get(id=player_id.decode(“utf8”))

class PlayerAuthMiddleware:
  def __init__(self, app):
    self.app = app

  async def __call__(self, scope, receive, send):
    scope[“player”] = await get_player(scope[“query_string”])
    return await self.app(scope, receive, send)

We will provide player.id in the query string and then use it to authenticate request. Our websocket url will be something like this: /match/1234–1234–1234–1234/?player-id Let’s update asgi.py (you should be already updated it when installing channels) to use PlayerAuthMiddleware

application = ProtocolTypeRouter({
  “http”: get_asgi_application(),
  “websocket”: PlayerAuthMiddleware(URLRouter(match.routing.websocket_urlpatterns)),
})

The last step to provide user access to our consumer is creating a router for it match/routing.py

websocket_urlpatterns = [re_path(r”match/(?P<match_id>.+)/$”, consumers.MatchConsumer.as_asgi()),]

Now you should be able to run the server and view index.html. In the next part of tutorial we will start working on the UI and will establish websocket connection between UI and AP