DEV Community

Andrey
Andrey

Posted on

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

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 https://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
https://channels.readthedocs.io/en/stable/installation.html 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)]},
  },
}
Enter fullscreen mode Exit fullscreen mode

Please make sure, you have installed and run redis service, you can find how to do that here https://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:
https://www.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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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’)
Enter fullscreen mode Exit fullscreen mode

And add this view to urls.py

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', index),
]
Enter fullscreen mode Exit fullscreen mode

Run the server and open in browser http://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__'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()),
]
Enter fullscreen mode Exit fullscreen mode

/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__'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()),
]
Enter fullscreen mode Exit fullscreen mode

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}))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)),
})
Enter fullscreen mode Exit fullscreen mode

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()),]
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)